diff --git a/native/include/deview.h b/native/include/deview.h index dcd182cf..93f2c3eb 100644 --- a/native/include/deview.h +++ b/native/include/deview.h @@ -127,6 +127,13 @@ typedef struct DeviewScreen { const DeviewQueueItem* queue; int32_t queueCount; + /* + * How many entries are pending in total, which is not queueCount: that is the visible slice, + * sized to the body and with the members of folded groups left out. A head that counted the + * slice reported "Pending (16)" beside "inline 1 of 30", and folding a group lowered it. + */ + int32_t pendingCount; + int32_t titleOffset; int32_t titleLength; int32_t subtitleOffset; @@ -215,7 +222,7 @@ typedef struct DeviewInput { * described. A widened array element, so an older library reads every pane after the first at * the wrong offset — this is the bump that matters most to honour. */ -#define DEVIEW_VERSION 6 +#define DEVIEW_VERSION 7 /* * The Swift implementation imports this header for the struct layouts, because Swift does not diff --git a/native/src/deview.cpp b/native/src/deview.cpp index c07bb122..10cf97de 100644 --- a/native/src/deview.cpp +++ b/native/src/deview.cpp @@ -110,6 +110,15 @@ struct State ImGuiContext* context = nullptr; DeviewInput input{}; + /* Whether the last screen carried a context menu, which is what makes Escape and a click + * outside it a dismissal rather than what they would otherwise mean. */ + bool menuOpen = false; + + /* What a wheel message left over. A notch is 1.0, and a touchpad sends fractions of one: + * truncating each frame's value on its own threw all of them away, so a touchpad scrolled + * nothing at all. */ + float scrollRemainder = 0.0f; + /* * The queue column, owned here rather than by the table. * @@ -384,6 +393,7 @@ void UpdateTexture(ImTextureData* texture) void RenderTriangles( unsigned int count, unsigned int indexStart, + unsigned int vertexOffset, const ImVector& indices, const ImVector& vertices, ImTextureID textureId) @@ -400,7 +410,12 @@ void RenderTriangles( { for (unsigned int corner = 0; corner < 3; corner++) { - const ImDrawVert& vertex = vertices[indices[indexStart + index + corner]]; + /* Plus the command's own vertex offset. ImDrawIdx is sixteen bits, so a draw list + * that runs past 65535 vertices - a maximised 4K window of dense long lines gets + * there - is split by ImGui into commands whose indices restart from a base recorded + * here. Without adding it the indices wrapped and the panes drew scrambled, and in a + * release build, with IM_ASSERT compiled out, nothing said so. */ + const ImDrawVert& vertex = vertices[vertexOffset + indices[indexStart + index + corner]]; const ImColor colour = ImColor(vertex.col); rlColor4f(colour.Value.x, colour.Value.y, colour.Value.z, colour.Value.w); rlTexCoord2f(vertex.uv.x, vertex.uv.y); @@ -448,6 +463,7 @@ void RenderDrawData(ImDrawData* drawData) RenderTriangles( command.ElemCount, command.IdxOffset, + command.VtxOffset, commands->IdxBuffer, commands->VtxBuffer, command.GetTexID()); @@ -695,6 +711,10 @@ void DrawPaneImage(const DeviewScreen* screen, const DeviewPane& pane, const Pan void BuildFrame(const DeviewScreen* screen) { + /* Read by the input pass, which has no screen of its own: Escape means dismiss while one of + * these is up, and quit otherwise. */ + state.menuOpen = screen->menuCount > 0; + const ImGuiViewport* viewport = ImGui::GetMainViewport(); ImGui::SetNextWindowPos(viewport->WorkPos); ImGui::SetNextWindowSize(viewport->WorkSize); @@ -785,7 +805,7 @@ void BuildFrame(const DeviewScreen* screen) if (index < screen->queueCount) { const DeviewQueueItem& item = screen->queue[index]; - const std::string label = Copy(screen, item.labelOffset, item.labelLength); + std::string label = Copy(screen, item.labelOffset, item.labelLength); if (item.flags & DEVIEW_QUEUE_HEADER) { /* A heading is dimmed like the subtitle, and never carries the selection. @@ -805,9 +825,14 @@ void BuildFrame(const DeviewScreen* screen) else { const bool selected = (item.flags & DEVIEW_QUEUE_SELECTED) != 0; - if (item.flags & DEVIEW_QUEUE_FAILED) + const bool failed = (item.flags & DEVIEW_QUEUE_FAILED) != 0; + if (failed) { ImGui::PushStyleColor(ImGuiCol_Text, RowColour(DEVIEW_ROW_REMOVED)); + /* The marker the other three heads and docs/viewer.md show. Colour + * alone says nothing to a reader who cannot tell this red from the + * one a removed line is drawn in, or from any other. */ + label += " !"; } ImGui::PushID(index); @@ -817,7 +842,7 @@ void BuildFrame(const DeviewScreen* screen) } ImGui::PopID(); - if (item.flags & DEVIEW_QUEUE_FAILED) + if (failed) { ImGui::PopStyleColor(); } @@ -981,9 +1006,22 @@ void BuildFrame(const DeviewScreen* screen) ImGui::PopID(); } + /* Asked before End, which is what makes it about this window. A click anywhere else is a + * dismissal: the menu used to float until a row, a button or a key was hit, contrary to + * what docs/viewer.md says of it. A right click elsewhere opens the next menu, and the + * managed side ignores a dismissal that arrives with one of those. */ + const bool overMenu = ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows); + ImGui::End(); ImGui::PopStyleVar(); ImGui::PopStyleColor(); + + if (!overMenu && + (ImGui::IsMouseClicked(ImGuiMouseButton_Left) || + ImGui::IsMouseClicked(ImGuiMouseButton_Right))) + { + state.input.menuClosed = 1; + } } for (int index = 0; index < screen->buttonCount; index++) @@ -1070,7 +1108,11 @@ int32_t deview_init( /* No MSAA. ImGui draws axis aligned quads with pre-antialiased glyph textures, so multisampling * buys nothing visually, and it is a real source of difference between a GPU and the software * rasteriser the pixel snapshots are pinned to. */ - unsigned int flags = FLAG_WINDOW_RESIZABLE; + /* ALWAYS_RUN because WindowShouldClose waits on events while the window is minimised, and + * that call is inside deview_present: without it the managed loop stops being pumped the + * moment the window is minimised, so a snapshot arriving after that is accepted by the + * listener and never shown. */ + unsigned int flags = FLAG_WINDOW_RESIZABLE | FLAG_WINDOW_ALWAYS_RUN; if (hidden != 0) { flags |= FLAG_WINDOW_HIDDEN; @@ -1090,6 +1132,10 @@ int32_t deview_init( ImGui::SetCurrentContext(state.context); ImGuiIO& io = ImGui::GetIO(); io.BackendFlags |= ImGuiBackendFlags_RendererHasTextures; + /* Declared, so ImGui splits a long draw list into commands with a vertex offset rather than + * refusing to let one grow past what a sixteen bit index can address. RenderTriangles applies + * the offset. */ + io.BackendFlags |= ImGuiBackendFlags_RendererHasVtxOffset; io.IniFilename = nullptr; io.LogFilename = nullptr; ApplyStyle(); @@ -1164,8 +1210,25 @@ void deview_poll_input(DeviewInput* input) if (state.initialised) { state.input.key = ReadKey(); + + /* Escape with a menu up dismisses the menu. It reached the managed side as quit, which + * closes the menu and then runs the command, so Esc-to-dismiss closed the viewer - and on + * Linux there is no tray to open it again from, so the queue went to staging. */ + if (state.menuOpen && + state.input.key == DEVIEW_KEY_QUIT && + IsKeyPressed(KEY_ESCAPE)) + { + state.input.key = DEVIEW_KEY_NONE; + state.input.menuClosed = 1; + } + + /* Whole notches, keeping the fraction. A touchpad sends a fraction of one per frame and + * truncating each frame on its own threw every one of them away. */ const Vector2 wheel = GetMouseWheelMoveV(); - state.input.scrollDelta = static_cast(wheel.y); + state.scrollRemainder += wheel.y; + const int32_t notches = static_cast(state.scrollRemainder); + state.input.scrollDelta = notches; + state.scrollRemainder -= static_cast(notches); MeasureGrid(); } @@ -1252,6 +1315,13 @@ void deview_focus(void) } ClearWindowState(FLAG_WINDOW_HIDDEN); + /* A minimised window stays minimised through SetWindowFocused, so a focus for a new snapshot + * left it in the taskbar. */ + if (IsWindowMinimized()) + { + RestoreWindow(); + } + SetWindowFocused(); } diff --git a/native/swift/Sources/Deview/Frame.swift b/native/swift/Sources/Deview/Frame.swift index adcdce5f..55b609f0 100644 --- a/native/swift/Sources/Deview/Frame.swift +++ b/native/swift/Sources/Deview/Frame.swift @@ -10,6 +10,11 @@ struct Frame { var subtitle = "" var status = "" var queue: [QueueItem] = [] + + /// Everything pending, which `queue` is not: that is the visible slice, sized to the body and + /// with the members of folded groups left out. + var pendingCount: Int32 = 0 + var buttons: [Button] = [] var left = Pane() var right = Pane() @@ -63,6 +68,7 @@ struct 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) + frame.pendingCount = screen.pendingCount if let items = screen.queue { for index in 0 ..< Int(screen.queueCount) { diff --git a/native/swift/Sources/Deview/Renderer.swift b/native/swift/Sources/Deview/Renderer.swift index 7070e617..2c7b0c67 100644 --- a/native/swift/Sources/Deview/Renderer.swift +++ b/native/swift/Sources/Deview/Renderer.swift @@ -172,9 +172,10 @@ final class Renderer { let headerTop = firstRule + Renderer.gap if hasQueue { - // Entries only: the rows include group headings, which are not pending anything. - let pending = frame.queue.filter { !$0.header }.count - text("Pending (\(pending))", in: rect(top: headerTop, left: Renderer.padding, width: queue, height: line, size), Palette.text, context) + // The count the managed side carries, not one derived from `queue`: that is the + // visible slice, so thirty pending in a sixteen row body read as "Pending (16)" beside + // "inline 1 of 30", and folding a group lowered it further. + text("Pending (\(frame.pendingCount))", in: rect(top: headerTop, left: Renderer.padding, width: queue, height: line, size), Palette.text, context) } text(frame.left.header, in: rect(top: headerTop, left: panesLeft, width: half, height: line, size), Palette.text, context) diff --git a/native/swift/Sources/Deview/ViewerView.swift b/native/swift/Sources/Deview/ViewerView.swift index fdb585ab..9c3c8745 100644 --- a/native/swift/Sources/Deview/ViewerView.swift +++ b/native/swift/Sources/Deview/ViewerView.swift @@ -52,14 +52,28 @@ final class ViewerView: NSView, NSViewToolTipOwner { /// /// The text is answered on demand below rather than stored here, so a row whose label changed /// under a resting cursor still reads correctly. + /// Rebuilt only when the regions themselves changed. AppKit times its tooltip delay from the + /// moment the cursor enters a tracking rectangle, and this runs on every frame, so removing + /// and re-adding the rectangle under a resting cursor restarted that delay before it could + /// ever elapse - which is to say queue tooltips never appeared on macOS at all. func refreshToolTips() { + let wanted = layout.queueItems.enumerated() + .filter { $0.offset < model.queue.count && !model.queue[$0.offset].tooltip.isEmpty } + .map(\.element) + guard wanted != toolTipRects else { + return + } + + toolTipRects = wanted removeAllToolTips() - for (index, bounds) in layout.queueItems.enumerated() - where index < model.queue.count && !model.queue[index].tooltip.isEmpty { + for bounds in wanted { _ = addToolTip(bounds, owner: self, userData: nil) } } + /// What the tips are registered on, so an unchanged frame can leave them alone. + private var toolTipRects: [NSRect] = [] + /// Composed by the managed side, so this only finds the row under the cursor. func view(_ view: NSView, stringForToolTip tag: NSView.ToolTipTag, point: NSPoint, userData: UnsafeMutableRawPointer?) -> String { guard let index = layout.queueItems.firstIndex(where: { $0.contains(point) }), @@ -132,12 +146,26 @@ final class ViewerView: NSView, NSViewToolTipOwner { super.rightMouseDown(with: event) } - /// Accumulated, because a trackpad delivers many small deltas between two polls and the - /// managed side amplifies whatever it is given. + /// A notch of a wheel, in the points a precise device reports one movement of it as. + /// + /// AppKit reports a wheel in lines and a trackpad in points, and rounding both to an integer + /// number of notches treated them as the same thing: an ordinary flick of a trackpad reads as + /// tens of points, so it arrived as tens of notches and the managed side then multiplied it + /// by three. Slow movement rounded to nothing at all. + private static let pointsPerNotch = 16.0 + + /// What is left over between events, because a trackpad delivers many small deltas between + /// two polls and dropping each one on its own is what made slow movement do nothing. + private var scrollRemainder = 0.0 + override func scrollWheel(with event: NSEvent) { - let notches = Int32(event.scrollingDeltaY.rounded()) + scrollRemainder += event.hasPreciseScrollingDeltas + ? event.scrollingDeltaY / ViewerView.pointsPerNotch + : event.scrollingDeltaY + let notches = scrollRemainder.rounded(.towardZero) + scrollRemainder -= notches if notches != 0 { - Runtime.shared.input.scrollDelta += notches + Runtime.shared.input.scrollDelta += Int32(notches) } } 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 91fe7b5b..95e0d0a4 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 099d3c18..b713b8c0 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 c7401893..1a8f5843 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 c7401893..1a8f5843 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/Native/Deview.cs b/src/DiffEngineViewer/Native/Deview.cs index d080e270..5e153de8 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 = 6; + public const int ExpectedVersion = 7; [LibraryImport(library, EntryPoint = "deview_version")] public static partial int Version(); diff --git a/src/DiffEngineViewer/Native/DeviewStructs.cs b/src/DiffEngineViewer/Native/DeviewStructs.cs index 6d68e080..75cea017 100644 --- a/src/DiffEngineViewer/Native/DeviewStructs.cs +++ b/src/DiffEngineViewer/Native/DeviewStructs.cs @@ -87,6 +87,11 @@ unsafe struct DeviewScreen public int ButtonCount; public DeviewQueueItem* Queue; public int QueueCount; + + /// + /// Everything pending, rather than the visible slice counts. + /// + public int PendingCount; public int TitleOffset; public int TitleLength; public int SubtitleOffset; diff --git a/src/DiffEngineViewer/Native/ScreenPayload.cs b/src/DiffEngineViewer/Native/ScreenPayload.cs index 2752dac1..361febbf 100644 --- a/src/DiffEngineViewer/Native/ScreenPayload.cs +++ b/src/DiffEngineViewer/Native/ScreenPayload.cs @@ -17,6 +17,7 @@ sealed class ScreenPayload int statusOffset; int statusLength; int menuRow; + int pendingCount; public void Build(Screen screen) { @@ -26,6 +27,7 @@ public void Build(Screen screen) queue.Clear(); menu.Clear(); menuRow = -1; + pendingCount = screen.PendingCount; (titleOffset, titleLength) = Add(screen.Title); (subtitleOffset, subtitleLength) = Add(screen.Subtitle); @@ -142,6 +144,7 @@ unsafe DeviewScreen Native( ButtonCount = buttons.Count, Queue = queuePtr, QueueCount = queue.Count, + PendingCount = pendingCount, TitleOffset = titleOffset, TitleLength = titleLength, SubtitleOffset = subtitleOffset,