diff --git a/native/src/deview.cpp b/native/src/deview.cpp index 70d9c5f2..f616f38e 100644 --- a/native/src/deview.cpp +++ b/native/src/deview.cpp @@ -19,6 +19,7 @@ #include #include +#include #include #include #include @@ -101,6 +102,9 @@ struct CachedTexture bool loaded = false; std::uintmax_t length = 0; std::filesystem::file_time_type written{}; + + /* Whether the frame being built asked for this picture. What ForgetUnusedPictures keeps. */ + bool used = false; }; struct State @@ -337,6 +341,7 @@ const Texture2D* Picture(const std::string& path) if (found->second.written == written && found->second.length == length) { + found->second.used = true; return found->second.loaded ? &found->second.texture : nullptr; } @@ -346,6 +351,7 @@ const Texture2D* Picture(const std::string& path) CachedTexture entry; entry.written = written; entry.length = length; + entry.used = true; const Texture2D texture = LoadTexture(path.c_str()); if (IsTextureValid(texture)) { @@ -360,6 +366,34 @@ const Texture2D* Picture(const std::string& path) return inserted->second.loaded ? &inserted->second.texture : nullptr; } +/* + * Drops every picture the frame just drawn did not ask for, once the frame has been rendered and + * the draw data naming those textures has been consumed. + * + * Otherwise an entry went only when its own path was asked for again and had changed or gone, so + * every image reviewed in a session stayed decoded, on the GPU, until the session ended. A picture + * scrolled or navigated back to is decoded again, which is one file read. + */ +void ForgetUnusedPictures() +{ + for (auto entry = state.pictures.begin(); entry != state.pictures.end();) + { + if (entry->second.used) + { + entry->second.used = false; + ++entry; + continue; + } + + if (entry->second.loaded) + { + UnloadTexture(entry->second.texture); + } + + entry = state.pictures.erase(entry); + } +} + void UnloadPictures() { for (auto& entry : state.pictures) @@ -467,7 +501,10 @@ void RenderDrawData(ImDrawData* drawData) rlDrawRenderBatchActive(); rlDisableBackfaceCulling(); - const float height = static_cast(GetScreenHeight()); + /* The height of what is being drawn to, which is not the window's for a capture: that draws to + * a render texture of its own size, and BeginTextureMode changes the target without changing + * what GetScreenHeight reports. */ + const float height = drawData->DisplaySize.y; for (int list = 0; list < drawData->CmdListsCount; list++) { const ImDrawList* commands = drawData->CmdLists[list]; @@ -606,8 +643,9 @@ struct PaneHit /* The left edge of the column, which is where the gutter starts. */ float cellLeft = -1.0f; - /* Where the row text starts, past that gutter, read from the first row that draws any. Stays - * -1 for a pane of nothing but filler, which has nothing to select either. */ + /* Where the row text starts, past that gutter, read from the first row that draws any, and + * true of every row because GutterDigits gives them all one width. Stays -1 for a pane of + * nothing but filler, which has nothing to select either. */ float textLeft = -1.0f; /* The top of row zero and the pitch between rows, read from the first two rows the way @@ -616,7 +654,38 @@ struct PaneHit float pitch = 0.0f; }; -void DrawRow(const DeviewScreen* screen, const DeviewPane& pane, int index, int column, PaneHit& hit) +/* + * How many digits the line numbers of this frame take: four, the width every other renderer + * gives them, or more when a row drawn in either pane needs it. + * + * One width for every row of both panes, which is what lets the text start read from the first + * row stand for all of them. Formatted per row, a five digit number pushed its own row's text a + * cell right of the rows above it, and a drag across them selected a cell off. + */ +int GutterDigits(const DeviewScreen* screen) +{ + int digits = 4; + for (int side = 0; side < 2 && side < screen->paneCount; side++) + { + const DeviewPane& pane = screen->panes[side]; + for (int index = 0; index < pane.rowCount; index++) + { + int32_t number = screen->rows[pane.rowOffset + index].lineNumber; + int length = 1; + while (number >= 10) + { + number /= 10; + length++; + } + + digits = std::max(digits, length); + } + } + + return digits; +} + +void DrawRow(const DeviewScreen* screen, const DeviewPane& pane, int index, int column, int digits, PaneHit& hit) { /* Before the row count check, so a pane shorter than the body still reports where its rows * begin and how far apart they are. */ @@ -652,11 +721,11 @@ void DrawRow(const DeviewScreen* screen, const DeviewPane& pane, int index, int ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(130, 130, 130, 255)); if (row.lineNumber >= 0) { - ImGui::Text("%c %4d", RowMarker(row.kind), row.lineNumber); + ImGui::Text("%c %*d", RowMarker(row.kind), digits, row.lineNumber); } else { - ImGui::Text("%c ", RowMarker(row.kind)); + ImGui::Text("%c %*s", RowMarker(row.kind), digits, ""); } ImGui::PopStyleColor(); @@ -1001,6 +1070,8 @@ void BuildFrame(const DeviewScreen* screen) PaneImage leftImage; PaneImage rightImage; + const int digits = GutterDigits(screen); + /* Filled by the same pass that draws the rows, and read after it by UpdateSelection. */ PaneHit leftHit; PaneHit rightHit; @@ -1011,7 +1082,11 @@ void BuildFrame(const DeviewScreen* screen) const DeviewPane& right = screen->panes[1]; if (hasQueue) { - ImGui::TableSetupColumn("Pending", ImGuiTableColumnFlags_WidthFixed, queueWidth); + /* The count every other renderer puts in this header, with the column's id kept apart + * from it so a count that changes is still the same column. */ + char pending[48]; + std::snprintf(pending, sizeof pending, "Pending (%d)###Pending", screen->pendingCount); + ImGui::TableSetupColumn(pending, ImGuiTableColumnFlags_WidthFixed, queueWidth); } ImGui::TableSetupColumn(Copy(screen, left.headerOffset, left.headerLength).c_str()); @@ -1111,10 +1186,10 @@ void BuildFrame(const DeviewScreen* screen) } RecordPaneImage(leftImage, left, index); - DrawRow(screen, left, index, column, leftHit); + DrawRow(screen, left, index, column, digits, leftHit); ImGui::TableSetColumnIndex(column + 1); RecordPaneImage(rightImage, right, index); - DrawRow(screen, right, index, column + 1, rightHit); + DrawRow(screen, right, index, column + 1, digits, rightHit); } ImGui::EndTable(); @@ -1428,6 +1503,7 @@ int32_t deview_present(const DeviewScreen* screen) ClearBackground(Color{24, 24, 24, 255}); RenderDrawData(ImGui::GetDrawData()); EndDrawing(); + ForgetUnusedPictures(); MeasureGrid(); return 1; @@ -1511,6 +1587,7 @@ int32_t deview_capture(const DeviewScreen* screen, int32_t width, int32_t height ClearBackground(Color{24, 24, 24, 255}); RenderDrawData(ImGui::GetDrawData()); EndTextureMode(); + ForgetUnusedPictures(); Image image = LoadImageFromTexture(target.texture); /* Render textures come back bottom up. */ diff --git a/native/swift/Sources/Deview/Exports.swift b/native/swift/Sources/Deview/Exports.swift index d30a0d69..c8b3c55a 100644 --- a/native/swift/Sources/Deview/Exports.swift +++ b/native/swift/Sources/Deview/Exports.swift @@ -8,6 +8,13 @@ import ImageIO /// /// 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. +/// +/// Every one that touches AppKit runs inside its own autorelease pool. `NSApplication.run` drains +/// a pool per event, but this app never calls it: the managed loop calls in instead, and with no +/// pool pushed objc4 parks everything autoreleased in one it creates for the thread, which drains +/// only when the thread exits. That is the main thread, so everything a frame autoreleased - the +/// events, the attributed strings, the drawing - stayed until the process did. GLFW, which also +/// pumps by hand, does the same. @_cdecl("deview_version") public func deviewVersion() -> Int32 { @@ -16,6 +23,19 @@ public func deviewVersion() -> Int32 { @_cdecl("deview_init") public func deviewInit( + _ width: Int32, + _ height: Int32, + _ title: UnsafePointer?, + _ fontTtf: UnsafePointer?, + _ fontLength: Int32, + _ fontSize: Float, + _ hidden: Int32) -> Int32 { + autoreleasepool { + initialise(width, height, title, fontTtf, fontLength, fontSize, hidden) + } +} + +private func initialise( _ width: Int32, _ height: Int32, _ title: UnsafePointer?, @@ -45,7 +65,10 @@ public func deviewPresent(_ screen: UnsafePointer?) -> Int32 { return 0 } - runtime.present(Frame.decode(screen)) + autoreleasepool { + runtime.present(Frame.decode(screen)) + } + return 1 } @@ -56,8 +79,10 @@ public func deviewPollInput(_ input: UnsafeMutablePointer?) { } let runtime = Runtime.shared - if runtime.initialised { - runtime.measureGrid() + autoreleasepool { + if runtime.initialised { + runtime.measureGrid() + } } input.pointee = runtime.input @@ -66,10 +91,12 @@ public func deviewPollInput(_ input: UnsafeMutablePointer?) { @_cdecl("deview_set_hidden") public func deviewSetHidden(_ hidden: Int32) { - if hidden == 0 { - Runtime.shared.show() - } else { - Runtime.shared.hide() + autoreleasepool { + if hidden == 0 { + Runtime.shared.show() + } else { + Runtime.shared.hide() + } } } @@ -81,19 +108,25 @@ public func deviewSetClipboard(_ text: UnsafePointer?) { // Cleared first: NSPasteboard keeps whatever types were declared before, so writing a string // over an image would otherwise leave both on the board and paste the wrong one. - let board = NSPasteboard.general - board.clearContents() - board.setString(String(cString: text), forType: .string) + autoreleasepool { + let board = NSPasteboard.general + board.clearContents() + board.setString(String(cString: text), forType: .string) + } } @_cdecl("deview_focus") public func deviewFocus() { - Runtime.shared.show() + autoreleasepool { + Runtime.shared.show() + } } @_cdecl("deview_shutdown") public func deviewShutdown() { - Runtime.shared.shutdown() + autoreleasepool { + Runtime.shared.shutdown() + } } /// Renders into a bitmap of this side's own making rather than asking the view for one. @@ -105,6 +138,16 @@ public func deviewShutdown() { /// snapshot tests do not need a window server. @_cdecl("deview_capture") public func deviewCapture( + _ screen: UnsafePointer?, + _ width: Int32, + _ height: Int32, + _ pngPath: UnsafePointer?) -> Int32 { + autoreleasepool { + capture(screen, width, height, pngPath) + } +} + +private func capture( _ screen: UnsafePointer?, _ width: Int32, _ height: Int32, diff --git a/native/swift/Sources/Deview/Renderer.swift b/native/swift/Sources/Deview/Renderer.swift index 3546d3f1..cbe753d2 100644 --- a/native/swift/Sources/Deview/Renderer.swift +++ b/native/swift/Sources/Deview/Renderer.swift @@ -212,6 +212,11 @@ final class Renderer { let bodyBottom = bodyTop + CGFloat(capacity) * line + // Only the pictures this frame names stay decoded. An entry used to go only when its own + // path was asked for again and had changed or gone, so every image reviewed in a session + // was held until the session ended. + pictures = pictures.filter { $0.key == frame.left.imagePath || $0.key == frame.right.imagePath } + // Under the rows rather than instead of them. The rows are what every head draws — format, // size and byte count, coloured against the other side — and this one can afford to also // show the thing they describe. diff --git a/native/swift/Sources/Deview/Runtime.swift b/native/swift/Sources/Deview/Runtime.swift index 655641e4..5df94017 100644 --- a/native/swift/Sources/Deview/Runtime.swift +++ b/native/swift/Sources/Deview/Runtime.swift @@ -11,6 +11,9 @@ final class Runtime { static let shared = Runtime() private var delegate: WindowDelegate? + + /// Held here because `NSApplication.delegate` does not keep it alive. + private let applicationDelegate = ApplicationDelegate() private var size = CGSize(width: 1100, height: 700) private var title = "DiffEngineViewer" @@ -80,6 +83,7 @@ final class Runtime { // Before finishLaunching, which is when the bar is first read. application.mainMenu = MainMenu.build(target) + application.delegate = applicationDelegate application.finishLaunching() let bounds = NSRect(origin: .zero, size: size) @@ -309,3 +313,24 @@ final class Runtime { input.dragFocusColumn = 0 } } + +/// Answers a quit that comes from outside the managed loop: Quit in the Dock, and logout. +/// +/// Both arrive as `terminate:`, which does not return. With no delegate to ask, AppKit exits from +/// inside it, so nothing after the pump call runs, the managed `finally` included. For a viewer +/// that owns the queue, that `finally` is `PersistOwned`, and macOS has no tray to hold the queue +/// instead, so the queue was lost. +/// +/// The quit is refused and reported as a close, which the managed side answers the way it answers +/// the window's close button: it leaves the loop, persists, and exits. That is what GLFW does. +/// Not `.terminateLater`, whose reply AppKit waits for in a modal loop inside the pump, while the +/// thread that would send the reply is the one blocked waiting for the pump to return. +/// +/// A refused quit also cancels the logout that asked for it. By the time the logout is tried +/// again the viewer has exited. +final class ApplicationDelegate: NSObject, NSApplicationDelegate { + func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { + Runtime.shared.input.closeRequested = 1 + return .terminateCancel + } +} diff --git a/native/swift/Sources/Deview/ViewerView.swift b/native/swift/Sources/Deview/ViewerView.swift index 72b4208f..d060f3f2 100644 --- a/native/swift/Sources/Deview/ViewerView.swift +++ b/native/swift/Sources/Deview/ViewerView.swift @@ -194,15 +194,19 @@ final class ViewerView: NSView, NSViewToolTipOwner { return (side, draggedRow(point.y, side: side), column(at: point.x, side: side)) } - /// The row under a y, in rows of the whole side and clamped into the body: a drag below the - /// last row means the last row rather than nothing. + /// The row under a y, in rows of the whole side and clamped into the rows the pane drew, as + /// Linux's `RowAt` does: a drag below the last row means the last row rather than nothing. + /// Clamped to the body instead, a drag past the end reached the rows the managed side holds + /// back below what it draws, so the status line counted, and a copy took, rows nobody saw + /// highlighted. private func draggedRow(_ y: CGFloat, side: Int32) -> Int32 { let line = renderer.cell.height let capacity = max(1, Int(layout.body.height / line)) + let pane = side == 1 ? model.right : model.left + let drawn = max(1, min(capacity, pane.rows.count)) // The context is not flipped, so the top of the body is its maxY and rows count downwards // from there. - let visible = min(max(Int((layout.body.maxY - y) / line), 0), capacity - 1) - let pane = side == 1 ? model.right : model.left + let visible = min(max(Int((layout.body.maxY - y) / line), 0), drawn - 1) return pane.scrollTop + Int32(visible) } diff --git a/src/DiffEngineViewer.Linux/runtimes/linux-arm64/native/libdiffengine_viewer.so b/src/DiffEngineViewer.Linux/runtimes/linux-arm64/native/libdiffengine_viewer.so index 8db5d48c..a58e9e34 100644 Binary files a/src/DiffEngineViewer.Linux/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.Linux/runtimes/linux-x64/native/libdiffengine_viewer.so b/src/DiffEngineViewer.Linux/runtimes/linux-x64/native/libdiffengine_viewer.so index f6bb8e1f..4d99b4fe 100644 Binary files a/src/DiffEngineViewer.Linux/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/runtimes/osx-arm64/native/libdiffengine_viewer.dylib b/src/DiffEngineViewer.Mac/runtimes/osx-arm64/native/libdiffengine_viewer.dylib index 5d30f898..bda1104e 100644 Binary files a/src/DiffEngineViewer.Mac/runtimes/osx-arm64/native/libdiffengine_viewer.dylib 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 index 5d30f898..bda1104e 100644 Binary files a/src/DiffEngineViewer.Mac/runtimes/osx-x64/native/libdiffengine_viewer.dylib and b/src/DiffEngineViewer.Mac/runtimes/osx-x64/native/libdiffengine_viewer.dylib differ diff --git a/src/DiffEngineViewer.Tests/NativeTests.cs b/src/DiffEngineViewer.Tests/NativeTests.cs index 15b10f5f..2cfeaa24 100644 --- a/src/DiffEngineViewer.Tests/NativeTests.cs +++ b/src/DiffEngineViewer.Tests/NativeTests.cs @@ -44,4 +44,30 @@ public async Task ShipsABinaryForThisPlatform() await Assert.That(NativeResolver.TryFind(out _)).IsTrue(); } + + /// + /// The probe for a musl RID is that RID alone. The linux-{arch} candidate after it names the + /// glibc build, which a musl process must not load. + /// + [Test] + [Arguments("linux-musl-x64")] + [Arguments("linux-musl-arm64")] + public async Task MuslProbesItsOwnRidAndNothingElse(string runtimeIdentifier) + { + var rids = NativeResolver.Rids(runtimeIdentifier).ToList(); + + await Assert.That(rids).IsEquivalentTo([runtimeIdentifier]); + } + + /// + /// Everywhere else the synthesised RID still follows the framework's own. + /// + [Test] + public async Task OtherRidsStillFallBackToTheSynthesisedRid() + { + var rids = NativeResolver.Rids("some-rid").ToList(); + + await Assert.That(rids.Count).IsGreaterThan(1); + await Assert.That(rids[0]).IsEqualTo("some-rid"); + } } diff --git a/src/DiffEngineViewer.Tests/PixelTests.ContextMenu.Linux.verified.png b/src/DiffEngineViewer.Tests/PixelTests.ContextMenu.Linux.verified.png index 6b33e167..5f6b895e 100644 Binary files a/src/DiffEngineViewer.Tests/PixelTests.ContextMenu.Linux.verified.png and b/src/DiffEngineViewer.Tests/PixelTests.ContextMenu.Linux.verified.png differ diff --git a/src/DiffEngineViewer.Tests/PixelTests.GroupedConflictedQueue.Linux.verified.png b/src/DiffEngineViewer.Tests/PixelTests.GroupedConflictedQueue.Linux.verified.png index 925c8883..ce77f713 100644 Binary files a/src/DiffEngineViewer.Tests/PixelTests.GroupedConflictedQueue.Linux.verified.png and b/src/DiffEngineViewer.Tests/PixelTests.GroupedConflictedQueue.Linux.verified.png differ diff --git a/src/DiffEngineViewer.Tests/PixelTests.InlineAccepted.Linux.verified.png b/src/DiffEngineViewer.Tests/PixelTests.InlineAccepted.Linux.verified.png index bf9eba54..ccf1eaba 100644 Binary files a/src/DiffEngineViewer.Tests/PixelTests.InlineAccepted.Linux.verified.png and b/src/DiffEngineViewer.Tests/PixelTests.InlineAccepted.Linux.verified.png differ diff --git a/src/DiffEngineViewer.Tests/PixelTests.InlineQueue.Linux.verified.png b/src/DiffEngineViewer.Tests/PixelTests.InlineQueue.Linux.verified.png index f9ff801c..0bd1a287 100644 Binary files a/src/DiffEngineViewer.Tests/PixelTests.InlineQueue.Linux.verified.png and b/src/DiffEngineViewer.Tests/PixelTests.InlineQueue.Linux.verified.png differ diff --git a/src/DiffEngineViewer.Tests/PixelTests.InlineSingle.Linux.verified.png b/src/DiffEngineViewer.Tests/PixelTests.InlineSingle.Linux.verified.png index 1a31035a..f965c5a1 100644 Binary files a/src/DiffEngineViewer.Tests/PixelTests.InlineSingle.Linux.verified.png and b/src/DiffEngineViewer.Tests/PixelTests.InlineSingle.Linux.verified.png differ diff --git a/src/DiffEngineViewer.Tests/PixelTests.LongQueueLabel.Linux.verified.png b/src/DiffEngineViewer.Tests/PixelTests.LongQueueLabel.Linux.verified.png index bd888ac4..b5810597 100644 Binary files a/src/DiffEngineViewer.Tests/PixelTests.LongQueueLabel.Linux.verified.png and b/src/DiffEngineViewer.Tests/PixelTests.LongQueueLabel.Linux.verified.png differ diff --git a/src/DiffEngineViewer.Windows.Tests/FormsHeadTests.cs b/src/DiffEngineViewer.Windows.Tests/FormsHeadTests.cs index 4cd0f803..80068532 100644 --- a/src/DiffEngineViewer.Windows.Tests/FormsHeadTests.cs +++ b/src/DiffEngineViewer.Windows.Tests/FormsHeadTests.cs @@ -736,20 +736,23 @@ sealed class CanvasHost : IDisposable readonly List bitmaps = []; - public ViewerCanvas Canvas { get; } = new() - { - Dock = DockStyle.Fill - }; + /// + /// Sized itself rather than docked to the form. A top level window is held to the screen's + /// size, so on a 1024 by 768 build agent every canvas docked to one came out 1028 by 749, + /// whatever was asked for. A child control has no such limit, and draws to a bitmap + /// whole whether or not it fits the form. + /// + public ViewerCanvas Canvas { get; } = new(); public CanvasHost(int width = 1100, int height = 700) { - form.ClientSize = new(width, height); + Canvas.Size = new(width, height); form.Controls.Add(Canvas); form.Show(); } public void Resize(int width, int height) => - form.ClientSize = new(width, height); + Canvas.Size = new(width, height); public Bitmap Draw(Screen screen) { diff --git a/src/DiffEngineViewer/Native/NativeResolver.cs b/src/DiffEngineViewer/Native/NativeResolver.cs index 39f62749..fbb747d3 100644 --- a/src/DiffEngineViewer/Native/NativeResolver.cs +++ b/src/DiffEngineViewer/Native/NativeResolver.cs @@ -74,11 +74,23 @@ static IEnumerable Candidates() yield return Path.Combine(root, file); } - static IEnumerable Rids() + static IEnumerable Rids() => + Rids(RuntimeInformation.RuntimeIdentifier); + + internal static IEnumerable Rids(string runtimeIdentifier) { // The exact RID first. On Alpine that is linux-musl-x64, which we do not ship, so the // probe simply misses rather than loading a glibc binary and hard crashing. - yield return RuntimeInformation.RuntimeIdentifier; + yield return runtimeIdentifier; + + // And nothing else, or the synthesised RID below undoes that: linux-{arch} is the glibc + // build. BundledViewerDirectory.Rids stops here for the same reason. A failed load is + // caught and the queue staged, but one that succeeded would leave a crash to come later, + // past the point where anything stages. + if (runtimeIdentifier.Contains("-musl-", StringComparison.Ordinal)) + { + yield break; + } var architecture = RuntimeInformation.OSArchitecture switch { diff --git a/todo.md b/todo.md index 71048f2c..4a6d75a9 100644 --- a/todo.md +++ b/todo.md @@ -18,44 +18,12 @@ Viewer model - Fix: have each head report string positions from its own layout rather than cells, or put every code point on the grid. -Native (the Linux items were unreachable until #885 made the Linux window draw and read input; these are verdicts on the code as it behaves since) - - -- [ ] **Decoded image caches never evict on Linux or macOS** (verified) - - `state.pictures` (`native/src/deview.cpp:140`) drops an entry only when that path is asked for again and has changed or gone (`:318-344`), or at shutdown; `Renderer.swift:60` likewise (`:436-452`). Bounded by one viewer session, which ends when the queue empties. - - Fix: after each frame, drop what the frame did not use. - -- [ ] **macOS drag-select clamps to the renderer's capacity rather than the rows drawn** (verified) - - `draggedRow` clamps to `capacity - 1` (`native/swift/Sources/Deview/ViewerView.swift:199-207`) while the managed side draws `Rows - 8` (`ScreenBuilder.cs:10-13`), and neither `DiffView.Unfold` nor `SelectionText.Clamp` clamps to the rows shown. An overshooting drag highlights to the last visible row, but the status line counts, and Cmd+C copies, up to three or four rows below it. - - Fix: clamp to the drawn rows, as Linux's `RowAt` does (`deview.cpp:693-703`). - -- [ ] **The Linux queue header ignores `pendingCount`** (verified) - - `deview.cpp:1014` draws the literal "Pending". macOS, WinForms and the text renderer draw "Pending (N)", which ABI 7 added the field for. - -- [ ] **`deview_capture` flips scissor rectangles with the window height** (verified, latent) - - `RenderDrawData` uses `GetScreenHeight()` (`deview.cpp:470`, `:484-489`), and `BeginTextureMode` changes only the render target's height. Every capture is 1100×700 in a 1100×700 window, so no output is wrong today. - - Fix: `drawData->DisplaySize.y`. - -- [ ] **Five-digit line numbers widen the Linux gutter, and hit-testing uses the first row's text start** (verified) - - `"%c %4d"` (`deview.cpp:655`) is seven cells from line 10000, and `textLeft` is read from the first row drawn (`:665-669`) and used for every row. Only files over 9999 lines; macOS uses a fixed eight-cell gutter. - -- [ ] **macOS has no autorelease pool around the hand-pumped frame** (verified; the leak rate needs a Mac) - - Nothing pushes a pool in `deview_present` or `deview_poll_input` (`native/swift/Sources/Deview/Exports.swift:41-65`, `Runtime.swift:131-150, 244-249`), and the dylib imports neither `objc_autoreleasePoolPush` nor `Pop`. objc4 then creates a pool that drains only when the thread exits. Check with `OBJC_DEBUG_MISSING_POOLS=YES`. - - Fix: `autoreleasepool {}` around each export's body, as GLFW does. - -- [ ] **macOS Dock Quit and logout skip `PersistOwned`** (verified) - - There is no app delegate and no `applicationShouldTerminate` (`Runtime.swift:92-96`). A quit Apple event becomes `terminate:`, after which cleanup in `main` never runs (Apple's `terminate(_:)` documentation), so #878's `finally` does not either. macOS has no tray, so an owning viewer's queue is lost. - - Fix, as GLFW does: an app delegate that records the quit and returns `.terminateCancel`. `.terminateLater` would deadlock, running a modal loop inside the pump while the managed thread waits. +Native - [ ] **macOS App Nap can stall the loop while the window is covered** (cannot verify here) - - Nothing opts out (no `beginActivity`, `NSAppSleepDisabled` or power assertion), the only wait is `nextEvent(until: now + 1/60)` (`Runtime.swift:244-249`), and a Focus queued by an arriving patch waits for the next managed frame. + - Nothing opts out (no `beginActivity`, `NSAppSleepDisabled` or power assertion), the only wait is `nextEvent(until: now + 1/60)` (`Runtime.swift:248-253`), and a Focus queued by an arriving patch waits for the next managed frame. - Check on a Mac: cover the viewer for a minute, confirm Activity Monitor shows App Nap, then time how long a failing inline test takes to bring it forward against an uncovered window. -- [ ] **`NativeResolver` loads the glibc build on musl through the `linux-{arch}` candidate** (cannot verify here) - - For `linux-musl-x64` the order is `runtimes/linux-musl-x64` (not shipped), then `runtimes/linux-x64` (glibc), then beside the exe (`src/DiffEngineViewer/Native/NativeResolver.cs:65-108`), against its own comment (`:79-80`). #788 fixed the same thing in `BundledViewerDirectory` but not here. - - A failed load is harmless: it is caught and the queue is staged before exit 4. All 238 undefined symbols are names musl exports, though, so the load could succeed, and a crash after it would skip staging. - - Check on Alpine x64 with mesa-gl, libx11, libxext, libsm, libice and libstdc++: whether the tool renders or crashes. - Library and inline - [ ] **With no tray, `dotnet test` does not return until the viewer it launched is closed** (repro)