diff --git a/docs/mdsource/viewer.source.md b/docs/mdsource/viewer.source.md index 2aac0c8c..3d71e77e 100644 --- a/docs/mdsource/viewer.source.md +++ b/docs/mdsource/viewer.source.md @@ -81,6 +81,9 @@ A test run that fails several inline snapshots produces one window, not several. binds the loopback port holds the queue; everything else hands its patch to that one. The window lists everything pending and offers **Accept all**. +The list sits in a column on the left. Drag the divider beside it to widen the column when the file +names are longer than it is. + Closing the window discards the queue, unless [DiffEngineTray](/docs/tray.md) is running, in which case the tray still has it and can reopen a window on it. diff --git a/docs/viewer.md b/docs/viewer.md index 323b2096..f503892d 100644 --- a/docs/viewer.md +++ b/docs/viewer.md @@ -88,6 +88,9 @@ A test run that fails several inline snapshots produces one window, not several. binds the loopback port holds the queue; everything else hands its patch to that one. The window lists everything pending and offers **Accept all**. +The list sits in a column on the left. Drag the divider beside it to widen the column when the file +names are longer than it is. + Closing the window discards the queue, unless [DiffEngineTray](/docs/tray.md) is running, in which case the tray still has it and can reopen a window on it. diff --git a/native/include/deview.h b/native/include/deview.h index 5cfc0ede..095270d6 100644 --- a/native/include/deview.h +++ b/native/include/deview.h @@ -138,8 +138,10 @@ typedef struct DeviewInput { * library is detected not crashed. * * 2: DeviewInput.columns and rows carry character cells rather than pixels. + * 3: deview_init's fontSize is an em size on both implementations. The ImGui one took it as a + * pixel height, so the same number rendered a quarter smaller there than on macOS. */ -#define DEVIEW_VERSION 2 +#define DEVIEW_VERSION 3 /* * The Swift implementation imports this header for the struct layouts, because Swift does not @@ -151,6 +153,10 @@ typedef struct DeviewInput { /* * Returns 1 on success. fontTtf may be NULL, in which case a built in font is used. * + * fontSize is an em size, which is the unit Core Text and GDI+ take and therefore the one all + * three heads have to agree on. An implementation whose rasteriser scales by something else, as + * ImGui's does by pixel height, converts. + * * 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 diff --git a/native/src/deview.cpp b/native/src/deview.cpp index 70d684e2..bdc7cc8e 100644 --- a/native/src/deview.cpp +++ b/native/src/deview.cpp @@ -12,6 +12,7 @@ #include "raylib.h" #include "rlgl.h" +#include #include #include @@ -35,16 +36,71 @@ void ClearCloseFlag() } } +/* + * Queue column widths, counted in character cells rather than pixels so a scaled display gets a + * column that holds the same number of characters rather than a narrower one. + */ +constexpr float queueCells = 34.0f; +constexpr float minQueueCells = 8.0f; + +/* + * What the drag leaves each of the two panes, so the splitter cannot be pushed far enough right to + * squeeze them out of existence. + */ +constexpr float minPaneCells = 12.0f; + +/* + * How far either side of the divider counts as grabbing it. The border is a single pixel, which is + * not something a mouse can be asked to hit. + */ +constexpr float grabWidth = 4.0f; + +/* + * deview_init's fontSize is an em size, which is what Core Text and GDI+ take and therefore what + * the other two heads render at. ImGui's stb_truetype loader scales by pixel height instead + * (stbtt_ScaleForPixelHeight in imgui_draw.cpp), so the same 15 came out as an em of about 11 and + * text a quarter smaller than the other heads, which is what left this head's queue column holding + * 34 characters in far fewer pixels. + * + * The correction is the font's own ascent plus descent over its em, and it is a constant because + * the only font that reaches here is the JetBrains Mono the managed side embeds: 1020 and 300 over + * 1000 units. Swapping that font means revisiting this number, hence naming it rather than folding + * it into the size. + */ +constexpr float emScale = 1.32f; + struct State { bool initialised = false; bool windowOpen = false; ImGuiContext* context = nullptr; DeviewInput input{}; + + /* + * The queue column, owned here rather than by the table. + * + * ImGuiTableFlags_Resizable would give the drag for free, but it also hands the width to + * ImGui's own table state, which initialises once and then auto-fits or restores from saved + * settings. A column that is fixed and not resizable takes InitStretchWeightOrWidth on every + * frame instead, which is a width this side decides and can therefore reproduce: the pixel + * captures share one context and one table id and draw a single frame each, so anything + * carried between them shows up as a snapshot that depends on the test order. + */ + float queueWidth = 0.0f; + + /* What was last handed to raylib, so an idle frame is not a window system call. */ + int cursor = MOUSE_CURSOR_DEFAULT; }; State state; +float ClampQueueWidth(float value, float available, float cell) +{ + const float low = cell * minQueueCells; + const float high = std::max(low, available - cell * minPaneCells * 2.0f); + return std::min(std::max(value, low), high); +} + void ResetInput() { state.input.key = DEVIEW_KEY_NONE; @@ -395,6 +451,21 @@ void BuildFrame(const DeviewScreen* screen) const bool hasQueue = screen->queueCount > 0; const int columns = hasQueue ? 3 : 2; + const float cell = ImGui::CalcTextSize("M").x; + if (state.queueWidth <= 0.0f) + { + state.queueWidth = cell * queueCells; + } + + /* The body, measured before the table so the drag zone can span all of it rather than only the + * rows the table happens to have. */ + const ImVec2 bodyMin = ImGui::GetCursorScreenPos(); + const ImVec2 bodyAvail = ImGui::GetContentRegionAvail(); + const float queueWidth = ClampQueueWidth(state.queueWidth, bodyAvail.x, cell); + + /* Where the border between the queue and the panes ended up, read back from the table rather + * than recomputed, and -1 until a row has been laid out. */ + float dividerX = -1.0f; if (screen->paneCount >= 2 && ImGui::BeginTable("##panes", columns, ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_SizingStretchSame)) { @@ -402,7 +473,7 @@ void BuildFrame(const DeviewScreen* screen) const DeviewPane& right = screen->panes[1]; if (hasQueue) { - ImGui::TableSetupColumn("Pending", ImGuiTableColumnFlags_WidthFixed, 220.0f); + ImGui::TableSetupColumn("Pending", ImGuiTableColumnFlags_WidthFixed, queueWidth); } ImGui::TableSetupColumn(Copy(screen, left.headerOffset, left.headerLength).c_str()); @@ -447,6 +518,11 @@ void BuildFrame(const DeviewScreen* screen) } ImGui::TableSetColumnIndex(column); + if (hasQueue && dividerX < 0.0f) + { + dividerX = ImGui::GetCursorScreenPos().x - ImGui::GetStyle().CellPadding.x; + } + DrawRow(screen, left, index, column); ImGui::TableSetColumnIndex(column + 1); DrawRow(screen, right, index, column + 1); @@ -455,6 +531,37 @@ void BuildFrame(const DeviewScreen* screen) ImGui::EndTable(); } + /* + * The drag, submitted after the table so it wins the overlap: within a window the last item to + * claim a position is the one that hovers. Inert in a capture, which never feeds a mouse + * button, so the width stays whatever this side decided. + */ + if (dividerX >= 0.0f) + { + const ImVec2 resume = ImGui::GetCursorScreenPos(); + ImGui::SetCursorScreenPos(ImVec2(dividerX - grabWidth, bodyMin.y)); + ImGui::InvisibleButton( + "##queue-splitter", + ImVec2(grabWidth * 2.0f + 1.0f, std::max(1.0f, bodyAvail.y))); + if (ImGui::IsItemHovered() || ImGui::IsItemActive()) + { + ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeEW); + } + + if (ImGui::IsItemActive()) + { + /* Moved by the distance between the cursor and the border it is dragging, rather than + * set from the cursor: a column's width is its inner width, and the border sits a + * padding and a spacing further right. A delta needs to know neither. */ + state.queueWidth = ClampQueueWidth( + queueWidth + ImGui::GetIO().MousePos.x - dividerX, + bodyAvail.x, + cell); + } + + ImGui::SetCursorScreenPos(resume); + } + ImGui::EndChild(); ImGui::Separator(); @@ -574,6 +681,7 @@ int32_t deview_init( memcpy(copy, fontTtf, static_cast(fontLength)); ImFontConfig config; config.FontDataOwnedByAtlas = true; + config.ExtraSizeScale = emScale; io.Fonts->AddFontFromMemoryTTF(copy, fontLength, fontSize <= 0.0f ? 15.0f : fontSize, &config); } @@ -605,6 +713,17 @@ int32_t deview_present(const DeviewScreen* screen) BuildFrame(screen); ImGui::Render(); + /* ImGui only records the cursor it wants. Showing it is the backend's job, and the splitter is + * the one thing here that asks for anything but an arrow. */ + const int cursor = ImGui::GetMouseCursor() == ImGuiMouseCursor_ResizeEW + ? MOUSE_CURSOR_RESIZE_EW + : MOUSE_CURSOR_DEFAULT; + if (cursor != state.cursor) + { + state.cursor = cursor; + SetMouseCursor(cursor); + } + BeginDrawing(); ClearBackground(Color{24, 24, 24, 255}); RenderDrawData(ImGui::GetDrawData()); diff --git a/native/swift/Sources/Deview/Renderer.swift b/native/swift/Sources/Deview/Renderer.swift index 4034b5a5..222e3855 100644 --- a/native/swift/Sources/Deview/Renderer.swift +++ b/native/swift/Sources/Deview/Renderer.swift @@ -12,7 +12,19 @@ import Foundation /// 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 + /// Queue column widths, counted in character cells rather than points so a scaled display gets + /// a column that holds the same number of characters rather than a narrower one. + private static let defaultQueueCells: CGFloat = 34 + private static let minQueueCells: CGFloat = 8 + + /// What the drag leaves each of the two panes, so the splitter cannot be pushed far enough + /// right to squeeze them out of existence. + private static let minPaneCells: CGFloat = 12 + + /// How far either side of the rule counts as grabbing it. The rule is a single point, which is + /// not something a mouse can be asked to hit. + private static let grab: CGFloat = 4 + private static let padding: CGFloat = 6 private static let gap: CGFloat = 4 @@ -24,6 +36,10 @@ final class Renderer { private let ascent: CGFloat private let descent: CGFloat + /// Moved by dragging the rule between the queue and the panes. Kept here rather than in the + /// view because this is what lays the rule out, and the drag has to land where it was drawn. + private var queueWidth: CGFloat = 0 + /// 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 @@ -33,6 +49,10 @@ final class Renderer { struct Layout { var buttons: [CGRect] = [] var queueItems: [CGRect] = [] + + /// The grab zone around the rule between the queue and the panes, empty when there is no + /// queue to divide off. + var splitter: CGRect = .zero } init(fontData: Data?, size: CGFloat) { @@ -50,6 +70,22 @@ final class Renderer { cell = CGSize( width: max(1, advance.width.rounded()), height: max(1, (ascent + descent + CTFontGetLeading(font)).rounded(.up))) + queueWidth = cell.width * Renderer.defaultQueueCells + } + + /// Clamped on every use rather than only when dragged, so shrinking the window narrows the + /// column instead of leaving the panes with nothing. + private func clamp(_ value: CGFloat, _ width: CGFloat) -> CGFloat { + let low = cell.width * Renderer.minQueueCells + let high = max( + low, + width - Renderer.padding * 2 - Renderer.gap - cell.width * Renderer.minPaneCells * 2) + return min(max(value, low), high) + } + + /// Puts the rule under the cursor. Called by the view while the splitter is being dragged. + func dragQueueWidth(to x: CGFloat, in width: CGFloat) { + queueWidth = clamp(x - Renderer.padding - Renderer.gap / 2, width) } private static func load(_ data: Data?, _ size: CGFloat) -> CTFont { @@ -83,7 +119,8 @@ final class Renderer { let line = cell.height let hasQueue = !frame.queue.isEmpty - let panesLeft = hasQueue ? Renderer.padding + Renderer.queueWidth + Renderer.gap : Renderer.padding + let queue = hasQueue ? clamp(queueWidth, size.width) : 0 + let panesLeft = hasQueue ? Renderer.padding + queue + Renderer.gap : Renderer.padding let panesWidth = max(cell.width * 2, size.width - Renderer.padding - panesLeft) let half = (panesWidth / 2).rounded(.down) @@ -98,7 +135,7 @@ final class Renderer { 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("Pending (\(frame.queue.count))", 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) @@ -113,7 +150,7 @@ final class Renderer { 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) + let bounds = rect(top: top, left: Renderer.padding, width: queue, height: line, size) layout.queueItems.append(bounds) queueItem(frame, index, bounds, context) } @@ -124,7 +161,14 @@ final class Renderer { let bodyBottom = bodyTop + CGFloat(capacity) * line if hasQueue { - columnRule(left: panesLeft - Renderer.gap / 2, top: bodyTop, bottom: bodyBottom, in: context, size) + let ruleLeft = panesLeft - Renderer.gap / 2 + columnRule(left: ruleLeft, top: bodyTop, bottom: bodyBottom, in: context, size) + layout.splitter = rect( + top: bodyTop, + left: ruleLeft - Renderer.grab, + width: Renderer.grab * 2 + 1, + height: bodyBottom - bodyTop, + size) } columnRule(left: panesLeft + half - Renderer.gap / 2, top: bodyTop, bottom: bodyBottom, in: context, size) @@ -173,7 +217,17 @@ final class Renderer { 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) + // Indented rather than offset: offsetBy keeps the width, which would let a long name clip + // one cell past the column instead of at it. + text( + label, + in: CGRect( + x: bounds.minX + cell.width, + y: bounds.minY, + width: bounds.width - cell.width, + height: bounds.height), + colour, + context) } private func row(_ pane: Frame.Pane, _ index: Int, _ bounds: CGRect, _ context: CGContext) { diff --git a/native/swift/Sources/Deview/ViewerView.swift b/native/swift/Sources/Deview/ViewerView.swift index 3d284b0f..26c00ced 100644 --- a/native/swift/Sources/Deview/ViewerView.swift +++ b/native/swift/Sources/Deview/ViewerView.swift @@ -6,6 +6,7 @@ import CDeview final class ViewerView: NSView { private let renderer: Renderer private var layout = Renderer.Layout() + private var draggingSplitter = false var model = Frame() @@ -30,7 +31,19 @@ final class ViewerView: NSView { return } + let previous = layout.splitter layout = renderer.draw(model, in: context, size: bounds.size) + if layout.splitter != previous { + window?.invalidateCursorRects(for: self) + } + } + + /// The resize cursor over the splitter, which is the only hint that it can be dragged. + override func resetCursorRects() { + super.resetCursorRects() + if !layout.splitter.isEmpty { + addCursorRect(layout.splitter, cursor: .resizeLeftRight) + } } override func mouseDown(with event: NSEvent) { @@ -40,12 +53,38 @@ final class ViewerView: NSView { return } + // Before the queue hit test, because the grab zone overlaps the right edge of the column + // and a drag that started there would otherwise also select whatever it began over. + if layout.splitter.contains(point) { + draggingSplitter = true + return + } + if let index = layout.queueItems.firstIndex(where: { $0.contains(point) }), index < model.queue.count { Runtime.shared.input.clickedQueueItem = Int32(index) } } + override func mouseDragged(with event: NSEvent) { + guard draggingSplitter else { + super.mouseDragged(with: event) + return + } + + renderer.dragQueueWidth(to: convert(event.locationInWindow, from: nil).x, in: bounds.width) + needsDisplay = true + } + + override func mouseUp(with event: NSEvent) { + if draggingSplitter { + draggingSplitter = false + return + } + + super.mouseUp(with: event) + } + /// 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) { 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 e4587cfa..dfd31eb8 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 a1093459..bad5b29a 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 d5d2fd1e..d319a1a9 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 d5d2fd1e..d319a1a9 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/InlineScreenTests.NewSnapshot.verified.txt b/src/DiffEngineViewer.Tests/InlineScreenTests.NewSnapshot.verified.txt index 9c1ad316..e389e11c 100644 --- a/src/DiffEngineViewer.Tests/InlineScreenTests.NewSnapshot.verified.txt +++ b/src/DiffEngineViewer.Tests/InlineScreenTests.NewSnapshot.verified.txt @@ -20,5 +20,5 @@ | | | | | | | | +----------------------+-----------------------------------+-----------------------------------+ -| [Accept] [Discard] (Accept all) lines 1-5 of 5 | +| [Accept] [Discard] [Accept all] lines 1-5 of 5 | +----------------------------------------------------------------------------------------------+ \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/InlineScreenTests.Single.verified.txt b/src/DiffEngineViewer.Tests/InlineScreenTests.Single.verified.txt index a7983d46..41519ebf 100644 --- a/src/DiffEngineViewer.Tests/InlineScreenTests.Single.verified.txt +++ b/src/DiffEngineViewer.Tests/InlineScreenTests.Single.verified.txt @@ -20,5 +20,5 @@ | | | | | | | | +----------------------+-----------------------------------+-----------------------------------+ -| [Accept] [Discard] (Accept all) lines 1-5 of 5 | +| [Accept] [Discard] [Accept all] lines 1-5 of 5 | +----------------------------------------------------------------------------------------------+ \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/InlineScreenTests.UnparsedExpression.verified.txt b/src/DiffEngineViewer.Tests/InlineScreenTests.UnparsedExpression.verified.txt index 375ee7ba..ddc1bc7d 100644 --- a/src/DiffEngineViewer.Tests/InlineScreenTests.UnparsedExpression.verified.txt +++ b/src/DiffEngineViewer.Tests/InlineScreenTests.UnparsedExpression.verified.txt @@ -20,5 +20,5 @@ | | | | | | | | +----------------------+-----------------------------------+-----------------------------------+ -| [Accept] [Discard] (Accept all) Existing expected argument is not a plain string literal. S> | +| [Accept] [Discard] [Accept all] Existing expected argument is not a plain string literal. S> | +----------------------------------------------------------------------------------------------+ \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/PixelTests.FileDiff.Linux.verified.png b/src/DiffEngineViewer.Tests/PixelTests.FileDiff.Linux.verified.png index b1b96baf..5bfad75d 100644 Binary files a/src/DiffEngineViewer.Tests/PixelTests.FileDiff.Linux.verified.png and b/src/DiffEngineViewer.Tests/PixelTests.FileDiff.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 b93a20d5..91e3ac96 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.InlineAccepted.OSX.verified.png b/src/DiffEngineViewer.Tests/PixelTests.InlineAccepted.OSX.verified.png index 330c1b23..f8a3ad9f 100644 Binary files a/src/DiffEngineViewer.Tests/PixelTests.InlineAccepted.OSX.verified.png and b/src/DiffEngineViewer.Tests/PixelTests.InlineAccepted.OSX.verified.png differ diff --git a/src/DiffEngineViewer.Tests/PixelTests.InlineQueue.Linux.verified.png b/src/DiffEngineViewer.Tests/PixelTests.InlineQueue.Linux.verified.png index 58c54bbe..608ee11b 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.InlineQueue.OSX.verified.png b/src/DiffEngineViewer.Tests/PixelTests.InlineQueue.OSX.verified.png index d5055ae9..edbf4c2c 100644 Binary files a/src/DiffEngineViewer.Tests/PixelTests.InlineQueue.OSX.verified.png and b/src/DiffEngineViewer.Tests/PixelTests.InlineQueue.OSX.verified.png differ diff --git a/src/DiffEngineViewer.Tests/PixelTests.InlineSingle.Linux.verified.png b/src/DiffEngineViewer.Tests/PixelTests.InlineSingle.Linux.verified.png index b48a3e1a..c206debc 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.InlineSingle.OSX.verified.png b/src/DiffEngineViewer.Tests/PixelTests.InlineSingle.OSX.verified.png index 31ed52d0..f2b91db9 100644 Binary files a/src/DiffEngineViewer.Tests/PixelTests.InlineSingle.OSX.verified.png and b/src/DiffEngineViewer.Tests/PixelTests.InlineSingle.OSX.verified.png differ diff --git a/src/DiffEngineViewer.Tests/PixelTests.LongQueueLabel.Linux.verified.png b/src/DiffEngineViewer.Tests/PixelTests.LongQueueLabel.Linux.verified.png new file mode 100644 index 00000000..904f410e Binary files /dev/null and b/src/DiffEngineViewer.Tests/PixelTests.LongQueueLabel.Linux.verified.png differ diff --git a/src/DiffEngineViewer.Tests/PixelTests.LongQueueLabel.OSX.verified.png b/src/DiffEngineViewer.Tests/PixelTests.LongQueueLabel.OSX.verified.png new file mode 100644 index 00000000..9d3053dc Binary files /dev/null and b/src/DiffEngineViewer.Tests/PixelTests.LongQueueLabel.OSX.verified.png differ diff --git a/src/DiffEngineViewer.Tests/PixelTests.cs b/src/DiffEngineViewer.Tests/PixelTests.cs index 82a1f702..ef3398f6 100644 --- a/src/DiffEngineViewer.Tests/PixelTests.cs +++ b/src/DiffEngineViewer.Tests/PixelTests.cs @@ -84,6 +84,18 @@ public Task InlineQueue() => Fixtures.Patch("SampleTests.cs", 88, "\"one\"", "two"), Fixtures.Patch("OtherTests.cs", 12, null, "brand new"))); + /// + /// A file name wider than the queue column, which has to stop at the divider rather than paint + /// over the pane beside it. The WinForms head has the same case, so all three are described. + /// + [Test] + [PixelTest] + public Task LongQueueLabel() => + Capture( + Fixtures.Inline( + Fixtures.Patch("HeaderPropagationExtensionsTests.cs", 130), + Fixtures.Patch())); + [Test] [PixelTest] public Task InlineAccepted() diff --git a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.InlineAccepted.verified.png b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.InlineAccepted.verified.png index eee8afd1..b4b2079c 100644 Binary files a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.InlineAccepted.verified.png 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 index fb6da255..4b50f767 100644 Binary files a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.InlineQueue.verified.png 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 index cf38231b..f80895aa 100644 Binary files a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.InlineSingle.verified.png and b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.InlineSingle.verified.png differ diff --git a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.LongQueueLabel.verified.png b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.LongQueueLabel.verified.png new file mode 100644 index 00000000..2ccc113e Binary files /dev/null and b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.LongQueueLabel.verified.png differ diff --git a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.cs b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.cs index 4bbdef44..292de832 100644 --- a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.cs +++ b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.cs @@ -51,6 +51,17 @@ public Task InlineQueue() => Fixtures.Patch("SampleTests.cs", 88, "\"one\"", "two"), Fixtures.Patch("OtherTests.cs", 12, null, "brand new"))); + /// + /// A file name wider than the queue column. It has to stop at the rule rather than paint over + /// the pane beside it, which is what GenericTypographic's NoClip used to let it do. + /// + [Test] + public Task LongQueueLabel() => + Capture( + Fixtures.Inline( + Fixtures.Patch("HeaderPropagationExtensionsTests.cs", 130), + Fixtures.Patch())); + [Test] public Task InlineAccepted() { diff --git a/src/DiffEngineViewer.Windows/Painter.cs b/src/DiffEngineViewer.Windows/Painter.cs index ac2674ec..ad8034a2 100644 --- a/src/DiffEngineViewer.Windows/Painter.cs +++ b/src/DiffEngineViewer.Windows/Painter.cs @@ -21,6 +21,11 @@ static StringFormat BuildFormat() { var format = (StringFormat) StringFormat.GenericTypographic.Clone(); format.FormatFlags |= StringFormatFlags.NoWrap; + // GenericTypographic arrives with NoClip and LineLimit set, and both have to go. NoClip is + // what let a long file name in the queue paint straight over the pane beside it. LineLimit + // then matters, because once clipping is on it turns a rect a pixel short of the measured + // line height into nothing drawn at all rather than a line clipped at the bottom. + format.FormatFlags &= ~(StringFormatFlags.NoClip | StringFormatFlags.LineLimit); format.Trimming = StringTrimming.None; return format; } diff --git a/src/DiffEngineViewer.Windows/ViewerCanvas.cs b/src/DiffEngineViewer.Windows/ViewerCanvas.cs index e878858c..a5fae695 100644 --- a/src/DiffEngineViewer.Windows/ViewerCanvas.cs +++ b/src/DiffEngineViewer.Windows/ViewerCanvas.cs @@ -15,7 +15,25 @@ [DesignerCategory("")] sealed class ViewerCanvas : Control { - const int queueWidth = 220; + /// + /// Queue column widths, counted in character cells rather than pixels so a scaled display gets + /// a column that holds the same number of characters rather than a narrower one. + /// + const int defaultQueueCells = 34; + + const int minQueueCells = 8; + + /// + /// What the drag leaves each of the two panes, so the splitter cannot be pushed far enough + /// right to squeeze them out of existence. + /// + const int minPaneCells = 12; + + /// + /// How far either side of the rule counts as grabbing it. The rule is a single pixel, which is + /// not something a mouse can be asked to hit. + /// + const int grab = 4; /// /// Marker, space, four digit line number, two spaces. Matches AsciiRenderer's gutter, so a @@ -29,6 +47,14 @@ sealed class ViewerCanvas : Control readonly Font font = MonoFont.Create(); Screen? screen; + /// + /// Zero until first asked for, because the default is counted in cells and a cell can only be + /// measured once a Graphics exists. + /// + int queueWidth; + + bool dragging; + public ViewerCanvas() { SetStyle( @@ -78,6 +104,41 @@ Size Cell int BodyTop => padding + (Cell.Height + gap) * 2 + gap * 2; + /// + /// Clamped on every read rather than only when dragged, so shrinking the window narrows the + /// column instead of leaving the panes with nothing. + /// + int QueueWidth + { + get + { + if (queueWidth == 0) + { + queueWidth = Cell.Width * defaultQueueCells; + } + + return Clamp(queueWidth); + } + } + + int Clamp(int value) + { + var min = Cell.Width * minQueueCells; + var max = Math.Max(min, Width - padding * 2 - gap - Cell.Width * minPaneCells * 2); + return Math.Min(Math.Max(value, min), max); + } + + /// + /// Where the rule between the queue and the panes is drawn, which is also what the drag moves. + /// + int SplitterX => + padding + QueueWidth + gap / 2; + + bool OverSplitter(int x) => + screen is not null && + screen.Queue.Count > 0 && + Math.Abs(x - SplitterX) <= grab; + protected override void OnPaint(PaintEventArgs e) { var graphics = e.Graphics; @@ -90,7 +151,8 @@ protected override void OnPaint(PaintEventArgs e) Painter.Prepare(graphics); var lineHeight = Cell.Height; var hasQueue = screen.Queue.Count > 0; - var panesLeft = hasQueue ? padding + queueWidth + gap : padding; + var queue = hasQueue ? QueueWidth : 0; + var panesLeft = hasQueue ? padding + queue + gap : padding; var panesWidth = Math.Max(2 * Cell.Width, Width - padding - panesLeft); var half = panesWidth / 2; @@ -102,7 +164,7 @@ protected override void OnPaint(PaintEventArgs e) var headerTop = firstRule + gap; if (hasQueue) { - Painter.Draw(graphics, $"Pending ({screen.Queue.Count})", font, Palette.Text, Cellular(padding, headerTop, queueWidth, lineHeight)); + Painter.Draw(graphics, $"Pending ({screen.Queue.Count})", font, Palette.Text, Cellular(padding, headerTop, queue, lineHeight)); } Painter.Draw(graphics, screen.Left.Header, font, Palette.Text, Cellular(panesLeft, headerTop, half, lineHeight)); @@ -117,7 +179,7 @@ protected override void OnPaint(PaintEventArgs e) var top = bodyTop + index * lineHeight; if (hasQueue) { - DrawQueueItem(graphics, index, new(padding, top, queueWidth, lineHeight)); + DrawQueueItem(graphics, index, new(padding, top, queue, lineHeight)); } DrawRow(graphics, screen.Left, index, new(panesLeft, top, half, lineHeight)); @@ -217,9 +279,22 @@ protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); if (screen is null || - screen.Queue.Count == 0 || - e.X < padding || - e.X >= padding + queueWidth) + screen.Queue.Count == 0) + { + return; + } + + // Checked before the queue hit test, because the grab zone overlaps the right edge of the + // column and a drag that started there would otherwise also select whatever it began over. + if (OverSplitter(e.X)) + { + dragging = true; + Capture = true; + return; + } + + if (e.X < padding || + e.X >= padding + QueueWidth) { return; } @@ -232,6 +307,54 @@ protected override void OnMouseDown(MouseEventArgs e) } } + protected override void OnMouseMove(MouseEventArgs e) + { + base.OnMouseMove(e); + if (dragging) + { + var width = Clamp(e.X - padding - gap / 2); + if (width != queueWidth) + { + queueWidth = width; + Invalidate(); + } + + return; + } + + // Assigned only on a change: setting Cursor is a window message, and this runs on every + // pixel the mouse moves over the canvas. + var wanted = OverSplitter(e.X) ? Cursors.VSplit : Cursors.Default; + if (Cursor != wanted) + { + Cursor = wanted; + } + } + + protected override void OnMouseUp(MouseEventArgs e) + { + base.OnMouseUp(e); + if (dragging) + { + dragging = false; + Capture = false; + } + } + + /// + /// The resize cursor is set while hovering the rule, so it has to be given back on the way out + /// rather than left on whatever the pointer moves onto next. + /// + protected override void OnMouseLeave(EventArgs e) + { + base.OnMouseLeave(e); + if (!dragging && + Cursor != Cursors.Default) + { + Cursor = Cursors.Default; + } + } + protected override void OnMouseWheel(MouseEventArgs e) { base.OnMouseWheel(e); diff --git a/src/DiffEngineViewer/Native/Deview.cs b/src/DiffEngineViewer/Native/Deview.cs index 4be64670..e3c8eb47 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 = 2; + public const int ExpectedVersion = 3; [LibraryImport(library, EntryPoint = "deview_version")] public static partial int Version(); diff --git a/src/DiffEngineViewer/Native/NativeViewerWindow.cs b/src/DiffEngineViewer/Native/NativeViewerWindow.cs index 6aaf0049..655c1045 100644 --- a/src/DiffEngineViewer/Native/NativeViewerWindow.cs +++ b/src/DiffEngineViewer/Native/NativeViewerWindow.cs @@ -49,6 +49,8 @@ static unsafe bool Init(string title, int width, int height, bool hidden, byte[] { fixed (byte* bytes = font) { + // An em size, which is what the ABI takes and what the WinForms head's 11pt works out + // as, so all three heads draw the same size text. return Deview.Init(width, height, title, bytes, font.Length, 15f, hidden ? 1 : 0) == 1; } } diff --git a/src/DiffEngineViewer/ScreenBuilder.cs b/src/DiffEngineViewer/ScreenBuilder.cs index 7d79a948..5aaad749 100644 --- a/src/DiffEngineViewer/ScreenBuilder.cs +++ b/src/DiffEngineViewer/ScreenBuilder.cs @@ -86,7 +86,9 @@ static IReadOnlyList