diff --git a/claude.md b/claude.md index 9e8677c1..931de197 100644 --- a/claude.md +++ b/claude.md @@ -165,6 +165,19 @@ apart. three fit from `ImagePane.Width/Height` — the file header's numbers, not the decoder's — one blank line under the pane's rows, so the placement rule lives once. Headers are sniffed by hand (`ImageHeader`) rather than by System.Drawing, which does not exist on macOS or Linux. +- Text selection is a view, and makes the same bargain images do. A drag arrives as both of its + ends at once, in rows of the whole side rather than of the visible slice: a head knows the scroll + top it drew the press with, so only it can resolve one that spans a wheel notch, and reporting + the pair every held frame rather than press/move/release events is what makes a whole + press-drag-release inside one frame arrive whole. `SessionState.Selection` names the entry it was + dragged in, so a stale one stops existing (`LiveSelection`) rather than needing a clear on every + transition, one of which would eventually be missed. `Row.Selection` is the run to highlight, + which the three pixel heads draw and `AsciiRenderer` cannot - a character grid has no way to + invert part of a line without changing its width - so what the model universally states about a + selection goes in the **status line**, where every renderer draws it, and the highlight is the + enrichment on top. Copying is `IViewerWindow.SetClipboard` rather than a `ViewerActions` member, + because a clipboard belongs to a toolkit the way a window does, and it is answered before the + owner link: the text is already in this process. - Queue tooltips are composed once in `QueueProjection`, not per head, and are **null when they would only repeat the row**. Labels are already the shortest distinguishing form, so the tip is what the label left off — path, test, frameworks, failure text. `QueueTooltipTests` snapshots the diff --git a/docs/mdsource/viewer.source.md b/docs/mdsource/viewer.source.md index 09ab66ee..45b35c2d 100644 --- a/docs/mdsource/viewer.source.md +++ b/docs/mdsource/viewer.source.md @@ -76,9 +76,20 @@ Nothing is written to disk for inline review. The patch travels over stdin, or o | `Shift+A` | Accept all | | `d` | Discard | | `v` | Cycle the variants of a conflicted snapshot (also the Variant button) | +| `Ctrl+A` | Select all of one pane | +| `Ctrl+C` | Copy the selection | | `q` `Esc` | Close | +## Selecting and copying + +Drag across either pane to select text, and `Ctrl+C` to copy it. `Ctrl+A` selects one whole pane: the one something is already selected in, or the received side when nothing is. On macOS the Edit menu carries both, so `Cmd+C` and `Cmd+A` work there too. + +A selection belongs to one pane. Dragging out of it keeps extending within the side the drag started in rather than crossing into the other, because the two sides are different documents. It survives scrolling, so a range taller than the window is a drag plus a wheel. The status line says how much is selected, and a click with no drag behind it clears it. + +What lands on the clipboard is what is on screen: tabs already expanded to the four spaces the panes draw them as, and no line numbers or change markers. Filler rows are left out — the blank lines that keep the two panes aligned where one side has no line are padding rather than content, so pasting a selection back gives the file's lines and nothing else. + + ## Multiple pending snapshots A test run that fails several inline snapshots produces one window, not several. Whichever process binds the loopback port holds the queue; everything else hands its patch to that one. The window lists everything pending and offers **Accept all**. @@ -103,12 +114,13 @@ Every row of the pending column answers a right-click: * An inline snapshot offers **Accept**, **Discard** and **Open source file**, plus **Show next variant** when frameworks disagree about it. * A move offers **Accept move**, **Discard** and **Open target directory**; a delete offers **Accept delete**, **Discard** and **Open directory**. * A solution header offers **Accept all in ...** and **Discard all in ...** for that solution only, and a test sub-header the same for that test's changes. Bulk accepts skip conflicted snapshots, the way accept-all does. + * Every entry also offers **Copy selection** when there is one, and a **Copy** item per pane, named after that pane, which copies the whole side. A side with nothing in it — the expected side of a brand new snapshot, or what is left after a delete — gets no item rather than one that copies nothing. Right-clicking an entry selects it first, so the menu acts on what is highlighted. Opening a file manager is always local — the files are on this machine, wherever the queue lives. On Windows and macOS this is the real OS menu, so it also takes the arrow keys, Enter, Escape and type-to-select, flips rather than clips near the edge of a screen, and is readable by a screen reader. A click that dismisses it is consumed doing so, which is why right-clicking a different row while a menu is open takes two clicks. On Linux it is drawn by the viewer, and any other click or key closes it. -macOS also carries a menu bar, listing the same commands as the keys below. +macOS also carries a menu bar: a Snapshot menu listing the same commands as the keys above, and an Edit menu with Copy and Select All. Those two are the only items carrying a key equivalent, because the rest are plain letters and a menu would match them before the window ever saw them. ## Grouping diff --git a/docs/viewer.md b/docs/viewer.md index ab4c2504..485cad47 100644 --- a/docs/viewer.md +++ b/docs/viewer.md @@ -83,9 +83,20 @@ Nothing is written to disk for inline review. The patch travels over stdin, or o | `Shift+A` | Accept all | | `d` | Discard | | `v` | Cycle the variants of a conflicted snapshot (also the Variant button) | +| `Ctrl+A` | Select all of one pane | +| `Ctrl+C` | Copy the selection | | `q` `Esc` | Close | +## Selecting and copying + +Drag across either pane to select text, and `Ctrl+C` to copy it. `Ctrl+A` selects one whole pane: the one something is already selected in, or the received side when nothing is. On macOS the Edit menu carries both, so `Cmd+C` and `Cmd+A` work there too. + +A selection belongs to one pane. Dragging out of it keeps extending within the side the drag started in rather than crossing into the other, because the two sides are different documents. It survives scrolling, so a range taller than the window is a drag plus a wheel. The status line says how much is selected, and a click with no drag behind it clears it. + +What lands on the clipboard is what is on screen: tabs already expanded to the four spaces the panes draw them as, and no line numbers or change markers. Filler rows are left out — the blank lines that keep the two panes aligned where one side has no line are padding rather than content, so pasting a selection back gives the file's lines and nothing else. + + ## Multiple pending snapshots A test run that fails several inline snapshots produces one window, not several. Whichever process binds the loopback port holds the queue; everything else hands its patch to that one. The window lists everything pending and offers **Accept all**. @@ -110,12 +121,13 @@ Every row of the pending column answers a right-click: * An inline snapshot offers **Accept**, **Discard** and **Open source file**, plus **Show next variant** when frameworks disagree about it. * A move offers **Accept move**, **Discard** and **Open target directory**; a delete offers **Accept delete**, **Discard** and **Open directory**. * A solution header offers **Accept all in ...** and **Discard all in ...** for that solution only, and a test sub-header the same for that test's changes. Bulk accepts skip conflicted snapshots, the way accept-all does. + * Every entry also offers **Copy selection** when there is one, and a **Copy** item per pane, named after that pane, which copies the whole side. A side with nothing in it — the expected side of a brand new snapshot, or what is left after a delete — gets no item rather than one that copies nothing. Right-clicking an entry selects it first, so the menu acts on what is highlighted. Opening a file manager is always local — the files are on this machine, wherever the queue lives. On Windows and macOS this is the real OS menu, so it also takes the arrow keys, Enter, Escape and type-to-select, flips rather than clips near the edge of a screen, and is readable by a screen reader. A click that dismisses it is consumed doing so, which is why right-clicking a different row while a menu is open takes two clicks. On Linux it is drawn by the viewer, and any other click or key closes it. -macOS also carries a menu bar, listing the same commands as the keys below. +macOS also carries a menu bar: a Snapshot menu listing the same commands as the keys above, and an Edit menu with Copy and Select All. Those two are the only items carrying a key equivalent, because the rest are plain letters and a menu would match them before the window ever saw them. ## Grouping diff --git a/native/include/deview.h b/native/include/deview.h index 93f2c3eb..4fa0963c 100644 --- a/native/include/deview.h +++ b/native/include/deview.h @@ -51,6 +51,18 @@ typedef struct DeviewRow { int32_t lineNumber; int32_t textOffset; int32_t textLength; + + /* + * What of this row the reader has selected, in characters of the text above rather than in + * pixels: the managed side flattens tabs before it counts, so a column here multiplied by the + * cell width is where the highlight goes. + * + * selectLength is 0 on a row with nothing selected, which is every row of almost every frame. + * The managed side has already resolved which side the drag is in and clipped the range to the + * visible slice, so a renderer only fills a rectangle and never decides what is in it. + */ + int32_t selectStart; + int32_t selectLength; } DeviewRow; typedef struct DeviewPane { @@ -168,7 +180,11 @@ enum DeviewKey { DEVIEW_KEY_DISCARD = 12, DEVIEW_KEY_ACCEPT_ALL = 13, DEVIEW_KEY_QUIT = 14, - DEVIEW_KEY_NEXT_VARIANT = 15 + DEVIEW_KEY_NEXT_VARIANT = 15, + /* Ctrl+C, and Cmd+C on macOS. */ + DEVIEW_KEY_COPY = 16, + /* Ctrl+A, which is why plain A must be reported as accept only when no modifier is held. */ + DEVIEW_KEY_SELECT_ALL = 17 }; typedef struct DeviewInput { @@ -203,6 +219,26 @@ typedef struct DeviewInput { */ int32_t columns; int32_t rows; + + /* + * A drag selecting pane text: which pane (0 left, 1 right, -1 for the frames with no button + * held over one), and both of its ends. + * + * Reported together for as long as the button is held and then simply not reported, rather + * than as press, move and release events. The managed side already holds the selection by + * then, so a release has nothing to add, and a whole press-drag-release landing inside one + * frame still arrives whole. + * + * Rows are rows of the whole side, not of the visible slice: this side drew the press with a + * scrollTop it still remembers, so only this side can resolve a drag that spans a wheel notch. + * Columns are characters of the row's text. Neither is clamped here — the managed side owns + * the text and does it there. + */ + int32_t dragSide; + int32_t dragAnchorRow; + int32_t dragAnchorColumn; + int32_t dragFocusRow; + int32_t dragFocusColumn; } DeviewInput; /* @@ -221,8 +257,15 @@ typedef struct DeviewInput { * 6: DeviewPane carries the picture the side is, so an image comparison is drawn rather than only * 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. + * 7: DeviewScreen carries the pending count, which is not the length of the queue it also carries: + * that is the visible slice, so a head deriving one from the other said "Pending (16)" beside + * "inline 1 of 30". + * 8: DeviewRow carries the selected run of its text and DeviewInput reports a drag across a pane, + * which between them are text selection. DeviewRow is a widened array element, so this is the + * same kind of bump 6 was. deview_set_clipboard is added beside them, because the selection is + * only worth having if it can be copied and each toolkit owns its own clipboard. */ -#define DEVIEW_VERSION 7 +#define DEVIEW_VERSION 8 /* * The Swift implementation imports this header for the struct layouts, because Swift does not @@ -266,6 +309,12 @@ DEVIEW_API int32_t deview_capture( DEVIEW_API void deview_set_hidden(int32_t hidden); +/* + * Puts UTF-8 text on the system clipboard. The toolkit's own clipboard rather than the managed + * side shelling out to a copy program, which is a thing every desktop has a different one of. + */ +DEVIEW_API void deview_set_clipboard(const char* text); + DEVIEW_API void deview_focus(void); DEVIEW_API void deview_shutdown(void); diff --git a/native/src/deview.cpp b/native/src/deview.cpp index 10cf97de..2db13fbe 100644 --- a/native/src/deview.cpp +++ b/native/src/deview.cpp @@ -138,6 +138,20 @@ struct State /* Keyed by the path the screen model handed over. std::map rather than unordered, because the * entries are handed out as pointers and this one does not move them. */ std::map pictures; + + /* + * A text selection being dragged out: whether the button is still down, which pane it went + * down in, and where. The side is fixed for the life of the drag, because a selection belongs + * to one pane and the other one's rows are a different document. + * + * The anchor is kept here rather than reported once, because the managed side takes both ends + * of a drag on every frame it is held. That is what makes a press and release landing inside + * a single frame arrive whole. + */ + bool dragging = false; + int32_t dragSide = -1; + int32_t dragAnchorRow = 0; + int32_t dragAnchorColumn = 0; }; State state; @@ -164,6 +178,13 @@ void ResetInput() * rather than "go to the top". */ state.input.scrollTo = -1; state.input.closeRequested = 0; + /* -1 for the same reason scrollTo is: 0 is the left pane, so a cleared field has to say "no + * drag" rather than "a drag in the left pane at row 0". */ + state.input.dragSide = -1; + state.input.dragAnchorRow = 0; + state.input.dragAnchorColumn = 0; + state.input.dragFocusRow = 0; + state.input.dragFocusColumn = 0; } /* Every string is an offset into one UTF-8 blob. Bad offsets are a crash, not a glitch, so the @@ -497,6 +518,20 @@ void PumpInput() int ReadKey() { + /* Super as well as control, so a macOS keyboard driving the Linux build through a remote + * session still copies with the chord its user has in their fingers. */ + const bool control = + IsKeyDown(KEY_LEFT_CONTROL) || IsKeyDown(KEY_RIGHT_CONTROL) || + IsKeyDown(KEY_LEFT_SUPER) || IsKeyDown(KEY_RIGHT_SUPER); + if (control) + { + /* Answered before the unmodified keys below, and returning none for anything else: without + * this ctrl+a fell through to plain A, which accepts. */ + if (IsKeyPressed(KEY_C)) return DEVIEW_KEY_COPY; + if (IsKeyPressed(KEY_A)) return DEVIEW_KEY_SELECT_ALL; + return DEVIEW_KEY_NONE; + } + if (IsKeyPressed(KEY_UP)) return DEVIEW_KEY_SCROLL_UP; if (IsKeyPressed(KEY_DOWN)) return DEVIEW_KEY_SCROLL_DOWN; if (IsKeyPressed(KEY_PAGE_UP)) return DEVIEW_KEY_PAGE_UP; @@ -540,8 +575,42 @@ void MeasureGrid() /* ---- the frame ---- */ -void DrawRow(const DeviewScreen* screen, const DeviewPane& pane, int index, int column) +/* + * Where a pane's rows landed, gathered while they are drawn rather than recomputed afterwards. + * The table owns the pane split and the gutter is a formatted string rather than a fixed number of + * cells, so asking the layout is the only way a drag can be resolved against the same numbers that + * drew the text it is selecting. + */ +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. */ + float textLeft = -1.0f; + + /* The top of row zero and the pitch between rows, read from the first two rows the way + * PaneImage reads them. */ + float first = -1.0f; + float pitch = 0.0f; +}; + +void DrawRow(const DeviewScreen* screen, const DeviewPane& pane, int index, int column, 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. */ + const ImVec2 origin = ImGui::GetCursorScreenPos(); + if (index == 0) + { + hit.cellLeft = origin.x; + hit.first = origin.y; + } + else if (index == 1 && hit.first >= 0.0f) + { + hit.pitch = origin.y - hit.first; + } + if (index >= pane.rowCount) { return; @@ -572,11 +641,147 @@ void DrawRow(const DeviewScreen* screen, const DeviewPane& pane, int index, int ImGui::PopStyleColor(); ImGui::SameLine(); + + const ImVec2 textPos = ImGui::GetCursorScreenPos(); + if (hit.textLeft < 0.0f) + { + hit.textLeft = textPos.x; + } + + /* Behind the text rather than over it, and the text keeps its own colour: what kind of change + * a line is has to survive being selected. The table's own clip rectangle keeps a run wider + * than the column inside it. */ + if (row.selectLength > 0) + { + const float cell = ImGui::CalcTextSize("M").x; + const ImVec2 min(textPos.x + static_cast(row.selectStart) * cell, textPos.y); + ImGui::GetWindowDrawList()->AddRectFilled( + min, + ImVec2( + min.x + static_cast(row.selectLength) * cell, + min.y + ImGui::GetTextLineHeight()), + IM_COL32(55, 92, 130, 255)); + } + ImGui::PushStyleColor(ImGuiCol_Text, RowColour(row.kind)); Text(screen, row.textOffset, row.textLength); ImGui::PopStyleColor(); } +/* The row of the visible slice a y is over, clamped into it: a drag below the last row means the + * last row rather than nothing. */ +int RowAt(const PaneHit& hit, float y, int rowCount) +{ + if (rowCount <= 0) + { + return 0; + } + + const float pitch = hit.pitch > 0.0f ? hit.pitch : ImGui::GetTextLineHeightWithSpacing(); + const int row = static_cast((y - hit.first) / pitch); + return std::min(std::max(row, 0), rowCount - 1); +} + +/* Rounded to the nearest boundary between characters rather than truncated to the one under the + * pointer, because a selection ends between two characters. Unclamped at the top: the managed side + * holds the text and pulls it back to the end of the line there. */ +int ColumnAt(const PaneHit& hit, float x, float cell) +{ + if (cell <= 0.0f) + { + return 0; + } + + if (hit.textLeft < 0.0f) + { + return 0; + } + + const int column = static_cast((x - hit.textLeft) / cell + 0.5f); + return std::max(column, 0); +} + +/* + * A drag across a pane, reduced to the two ends the managed side takes. + * + * Nothing here decides what is selected: the rows are reported in rows of the whole side, using + * the scroll top the frame was drawn with, so a drag that spans a wheel notch still means what it + * meant when it started. + */ +void UpdateSelection( + const DeviewScreen* screen, + const PaneHit& leftHit, + const PaneHit& rightHit, + const ImVec2& bodyMin, + const ImVec2& bodyAvail, + float dividerX, + float cell) +{ + if (screen->paneCount < 2) + { + return; + } + + /* A capture draws one frame in a fresh context that was never fed a mouse, so there is no + * position to resolve anything against - and a pointer that left the window is the same + * answer. */ + if (!ImGui::IsMousePosValid()) + { + state.dragging = false; + return; + } + + const ImVec2 mouse = ImGui::GetIO().MousePos; + if (!state.dragging) + { + if (!ImGui::IsMouseClicked(ImGuiMouseButton_Left) || + /* This head draws its own context menu, so a click on one lands on the panes as far as + * anything here can tell. The other two heads use a real popup, whose tracking loop + * swallows the click before a view ever sees it. */ + screen->menuCount > 0 || + mouse.y < bodyMin.y || + mouse.y > bodyMin.y + bodyAvail.y || + mouse.x > bodyMin.x + bodyAvail.x || + leftHit.cellLeft < 0.0f || + leftHit.textLeft < 0.0f || + mouse.x < leftHit.cellLeft || + /* The splitter's grab zone overlaps the left pane's edge, and a drag that started + * there would otherwise also select whatever it began over. */ + (dividerX >= 0.0f && mouse.x <= dividerX + grabWidth)) + { + return; + } + + const bool right = rightHit.cellLeft >= 0.0f && mouse.x >= rightHit.cellLeft; + if (right && rightHit.textLeft < 0.0f) + { + return; + } + + const PaneHit& hit = right ? rightHit : leftHit; + const DeviewPane& pane = screen->panes[right ? 1 : 0]; + state.dragging = true; + state.dragSide = right ? 1 : 0; + state.dragAnchorRow = pane.scrollTop + RowAt(hit, mouse.y, pane.rowCount); + state.dragAnchorColumn = ColumnAt(hit, mouse.x, cell); + } + + const PaneHit& hit = state.dragSide == 1 ? rightHit : leftHit; + const DeviewPane& pane = screen->panes[state.dragSide == 1 ? 1 : 0]; + state.input.dragSide = state.dragSide; + state.input.dragAnchorRow = state.dragAnchorRow; + state.input.dragAnchorColumn = state.dragAnchorColumn; + state.input.dragFocusRow = pane.scrollTop + RowAt(hit, mouse.y, pane.rowCount); + state.input.dragFocusColumn = ColumnAt(hit, mouse.x, cell); + + /* Reported one last time on the frame the button came up, and then not at all: the managed + * side is already holding the selection, so a release has nothing left to say. */ + if (!ImGui::IsMouseDown(ImGuiMouseButton_Left)) + { + state.dragging = false; + } +} + /* * Where a pane's picture goes, gathered from the table that drew the rows rather than recomputed. * The table owns the pane split, so asking it is the only way to place something under a column @@ -775,6 +980,10 @@ void BuildFrame(const DeviewScreen* screen) * common frame, where neither side is a picture. */ PaneImage leftImage; PaneImage rightImage; + + /* Filled by the same pass that draws the rows, and read after it by UpdateSelection. */ + PaneHit leftHit; + PaneHit rightHit; if (screen->paneCount >= 2 && ImGui::BeginTable("##panes", columns, ImGuiTableFlags_BordersInnerV | ImGuiTableFlags_SizingStretchSame)) { @@ -882,10 +1091,10 @@ void BuildFrame(const DeviewScreen* screen) } RecordPaneImage(leftImage, left, index); - DrawRow(screen, left, index, column); + DrawRow(screen, left, index, column, leftHit); ImGui::TableSetColumnIndex(column + 1); RecordPaneImage(rightImage, right, index); - DrawRow(screen, right, index, column + 1); + DrawRow(screen, right, index, column + 1, rightHit); } ImGui::EndTable(); @@ -898,6 +1107,10 @@ void BuildFrame(const DeviewScreen* screen) DrawPaneImage(screen, screen->panes[1], rightImage, bottom); } + /* After the table, which is where the geometry it reads becomes complete, and before the + * splitter, which claims its own clicks. */ + UpdateSelection(screen, leftHit, rightHit, bodyMin, bodyAvail, dividerX, cell); + /* * 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 @@ -1307,6 +1520,20 @@ void deview_set_hidden(int32_t hidden) ClearWindowState(FLAG_WINDOW_HIDDEN); } +void deview_set_clipboard(const char* text) +{ + /* GLFW owns the clipboard and needs its window, so a runtime that never opened one - a capture + * host - copies nothing rather than crashing. */ + if (text == nullptr || + !state.initialised || + !state.windowOpen) + { + return; + } + + SetClipboardText(text); +} + void deview_focus(void) { if (!state.initialised) diff --git a/native/swift/Sources/Deview/Exports.swift b/native/swift/Sources/Deview/Exports.swift index 6267c3fc..d30a0d69 100644 --- a/native/swift/Sources/Deview/Exports.swift +++ b/native/swift/Sources/Deview/Exports.swift @@ -4,7 +4,7 @@ import CoreGraphics import Foundation import ImageIO -/// The eight entry points of native/include/deview.h, implemented over AppKit and Core Text. +/// The nine entry points of native/include/deview.h, implemented over AppKit and Core Text. /// /// The header is imported for its struct layouts only, with DEVIEW_TYPES_ONLY, so these are the /// definitions of those symbols rather than a second declaration of them. @@ -73,6 +73,19 @@ public func deviewSetHidden(_ hidden: Int32) { } } +@_cdecl("deview_set_clipboard") +public func deviewSetClipboard(_ text: UnsafePointer?) { + guard let text else { + return + } + + // 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) +} + @_cdecl("deview_focus") public func deviewFocus() { Runtime.shared.show() diff --git a/native/swift/Sources/Deview/Frame.swift b/native/swift/Sources/Deview/Frame.swift index 55b609f0..f5a42d1f 100644 --- a/native/swift/Sources/Deview/Frame.swift +++ b/native/swift/Sources/Deview/Frame.swift @@ -28,6 +28,13 @@ struct Frame { var kind: Int32 = 0 var lineNumber: Int32 = -1 var text = "" + + /// What of `text` the reader has selected, in characters. Length 0 on a row with nothing + /// selected, which is every row of almost every frame. The managed side has already + /// resolved which side the drag is in and clipped the range to the visible slice, so this + /// is a rectangle to fill rather than a range to work out. + var selectStart: Int32 = 0 + var selectLength: Int32 = 0 } struct Pane { @@ -132,7 +139,9 @@ struct Frame { Row( kind: row.kind, lineNumber: row.lineNumber, - text: string(screen, row.textOffset, row.textLength))) + text: string(screen, row.textOffset, row.textLength), + selectStart: row.selectStart, + selectLength: row.selectLength)) } return pane diff --git a/native/swift/Sources/Deview/MainMenu.swift b/native/swift/Sources/Deview/MainMenu.swift index 55deb47f..89826f11 100644 --- a/native/swift/Sources/Deview/MainMenu.swift +++ b/native/swift/Sources/Deview/MainMenu.swift @@ -57,6 +57,7 @@ enum MainMenu { let name = ProcessInfo.processInfo.processName let bar = NSMenu() bar.addItem(submenu(NSMenu(title: name), items: application(target, name))) + bar.addItem(submenu(NSMenu(title: "Edit"), items: edit(target))) bar.addItem(submenu(NSMenu(title: "Snapshot"), items: snapshot(target))) bar.addItem(submenu(NSMenu(title: "Window"), items: window())) return bar @@ -78,6 +79,17 @@ enum MainMenu { ] } + /// The two commands that do carry key equivalents, because both are chords rather than plain + /// letters and so cannot swallow the keystrokes `ViewerView.keyDown` exists to read. They are + /// also the two a macOS reader will try before reading any documentation. + private static func edit(_ target: ControlTarget) -> [NSMenuItem] { + let copy = command("Copy", DEVIEW_KEY_COPY, target) + copy.keyEquivalent = "c" + let selectAll = command("Select All", DEVIEW_KEY_SELECT_ALL, target) + selectAll.keyEquivalent = "a" + return [copy, selectAll] + } + /// The keymap the docs publish, one item per command, so it is discoverable rather than only /// documented. /// diff --git a/native/swift/Sources/Deview/Palette.swift b/native/swift/Sources/Deview/Palette.swift index aa3fc482..410ed5d2 100644 --- a/native/swift/Sources/Deview/Palette.swift +++ b/native/swift/Sources/Deview/Palette.swift @@ -37,6 +37,12 @@ enum Palette { /// composite, so the queue highlight matches without carrying an alpha channel around. static let selected = rgb(38, 64, 90) + /// Behind selected pane text. Brighter than the queue's row highlight so the two do not read + /// as the same thing, and dark enough that added green and removed red are still legible over + /// it - selected text keeps its own colour, because what kind of change a line is does not + /// stop mattering while it is being copied. + static let selection = rgb(55, 92, 130) + static let buttonFace = grey(52) static let buttonDisabled = grey(34) diff --git a/native/swift/Sources/Deview/Renderer.swift b/native/swift/Sources/Deview/Renderer.swift index 2c7b0c67..9aa7e3d5 100644 --- a/native/swift/Sources/Deview/Renderer.swift +++ b/native/swift/Sources/Deview/Renderer.swift @@ -86,6 +86,17 @@ final class Renderer { /// The rows region, which is what a scroller spans and what the tooltips sit inside. var body: CGRect = .zero + + /// Where the two panes' columns ended up, left then right, so a drag selecting text is + /// resolved against the same numbers that drew it. + var panes: [PaneColumn] = [] + } + + /// One pane's horizontal extent: the column, and where its row text starts past the gutter. + struct PaneColumn { + var cellLeft: CGFloat = 0 + var textLeft: CGFloat = 0 + var width: CGFloat = 0 } init(fontData: Data?, size: CGFloat) { @@ -226,6 +237,14 @@ final class Renderer { width: content - Renderer.padding * 2, height: bodyBottom - bodyTop, size) + let gutter = Renderer.gutterCells * cell.width + layout.panes = [ + PaneColumn(cellLeft: panesLeft, textLeft: panesLeft + gutter, width: half), + PaneColumn( + cellLeft: panesLeft + half, + textLeft: panesLeft + half + gutter, + width: panesWidth - half) + ] layout.buttons = footer(frame, size: size, height: footerHeight, line: line, in: context) return layout } @@ -307,6 +326,20 @@ final class Renderer { let number = String(row.lineNumber) let gutter = "\(Palette.marker(row.kind)) \(String(repeating: " ", count: max(0, 4 - number.count)))\(number)" let width = Renderer.gutterCells * cell.width + + // Behind the text rather than over it, and the text keeps its own colour: what kind of + // change a line is has to survive being selected. + if row.selectLength > 0 { + context.setFillColor(Palette.selection) + context.fill( + CGRect( + x: bounds.minX + width + CGFloat(row.selectStart) * cell.width, + y: bounds.minY, + width: CGFloat(row.selectLength) * cell.width, + height: bounds.height) + .intersection(bounds)) + } + text(gutter, in: CGRect(x: bounds.minX, y: bounds.minY, width: width, height: bounds.height), Palette.dim, context) text( row.text, diff --git a/native/swift/Sources/Deview/Runtime.swift b/native/swift/Sources/Deview/Runtime.swift index c89df882..655641e4 100644 --- a/native/swift/Sources/Deview/Runtime.swift +++ b/native/swift/Sources/Deview/Runtime.swift @@ -300,5 +300,12 @@ final class Runtime { // target" rather than "go to the top". input.scrollTo = -1 input.closeRequested = 0 + // -1 for the same reason scrollTo is: 0 is the left pane, so a cleared field has to say + // "no drag" rather than "a drag in the left pane at row 0". + input.dragSide = -1 + input.dragAnchorRow = 0 + input.dragAnchorColumn = 0 + input.dragFocusRow = 0 + input.dragFocusColumn = 0 } } diff --git a/native/swift/Sources/Deview/ViewerView.swift b/native/swift/Sources/Deview/ViewerView.swift index 9c3c8745..96ab3a80 100644 --- a/native/swift/Sources/Deview/ViewerView.swift +++ b/native/swift/Sources/Deview/ViewerView.swift @@ -7,6 +7,14 @@ final class ViewerView: NSView, NSViewToolTipOwner { private let renderer: Renderer private var draggingSplitter = false + /// Whether the left button is down over a pane, and where it went down. The side is fixed for + /// the life of the drag: a selection belongs to one pane, so crossing into the other extends + /// within the first rather than jumping. + private var selecting = false + private var selectSide: Int32 = 0 + private var selectAnchorRow: Int32 = 0 + private var selectAnchorColumn: Int32 = 0 + /// Where the last frame put things. Read by `Runtime` to anchor the context menu, which is a /// real `NSMenu` and so is popped from outside the drawing code. private(set) var layout = Renderer.Layout() @@ -113,17 +121,42 @@ final class ViewerView: NSView, NSViewToolTipOwner { if let index = layout.queueItems.firstIndex(where: { $0.contains(point) }), index < model.queue.count { Runtime.shared.input.clickedQueueItem = Int32(index) + return + } + + // Not gated on there being a queue: file mode has two panes and no column, and its text is + // as worth copying as anything else. + guard let cell = paneCell(at: point) else { + return } + + selecting = true + selectSide = cell.side + selectAnchorRow = cell.row + selectAnchorColumn = cell.column + // Both ends on the press, so a click with no drag behind it reports an empty selection, + // which is what clears the previous one. + report(focusRow: cell.row, focusColumn: cell.column) } override func mouseDragged(with event: NSEvent) { - guard draggingSplitter else { + let point = convert(event.locationInWindow, from: nil) + if draggingSplitter { + renderer.dragQueueWidth(to: point.x, in: bounds.width) + needsDisplay = true + return + } + + guard selecting else { super.mouseDragged(with: event) return } - renderer.dragQueueWidth(to: convert(event.locationInWindow, from: nil).x, in: bounds.width) - needsDisplay = true + // Against the side the press landed in, whatever the pointer has wandered over since: a + // selection is one pane's, and the other pane's rows are a different document. + report( + focusRow: draggedRow(point.y, side: selectSide), + focusColumn: column(at: point.x, side: selectSide)) } override func mouseUp(with event: NSEvent) { @@ -132,9 +165,67 @@ final class ViewerView: NSView, NSViewToolTipOwner { return } + if selecting { + // Nothing to report: the managed side is already holding the selection, so a release + // has nothing left to say. + selecting = false + return + } + super.mouseUp(with: event) } + /// The pane cell under a point, or nil when the point is not over one. Rows are rows of the + /// whole side rather than of the visible slice, since that is what a selection is anchored in + /// and only this side knows the scroll top the frame was drawn with. + private func paneCell(at point: NSPoint) -> (side: Int32, row: Int32, column: Int32)? { + guard layout.panes.count == 2, + !layout.body.isEmpty, + renderer.cell.height > 0, + point.y >= layout.body.minY, + point.y <= layout.body.maxY, + point.x >= layout.panes[0].cellLeft, + point.x <= layout.body.maxX + else { + return nil + } + + let side: Int32 = point.x >= layout.panes[1].cellLeft ? 1 : 0 + 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. + private func draggedRow(_ y: CGFloat, side: Int32) -> Int32 { + let line = renderer.cell.height + let capacity = max(1, Int(layout.body.height / line)) + // 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 + return pane.scrollTop + Int32(visible) + } + + /// Rounded to the nearest boundary between characters rather than truncated to the one under + /// the pointer, because a selection ends between two characters. Unclamped at the top: the + /// managed side holds the text and pulls it back to the end of the line there. + private func column(at x: CGFloat, side: Int32) -> Int32 { + guard layout.panes.count == 2, renderer.cell.width > 0 else { + return 0 + } + + let textLeft = layout.panes[Int(side)].textLeft + return Int32(max(0, ((x - textLeft) / renderer.cell.width + 0.5).rounded(.down))) + } + + private func report(focusRow: Int32, focusColumn: Int32) { + Runtime.shared.input.dragSide = selectSide + Runtime.shared.input.dragAnchorRow = selectAnchorRow + Runtime.shared.input.dragAnchorColumn = selectAnchorColumn + Runtime.shared.input.dragFocusRow = focusRow + Runtime.shared.input.dragFocusColumn = focusColumn + } + override func rightMouseDown(with event: NSEvent) { let point = convert(event.locationInWindow, from: nil) if let index = layout.queueItems.firstIndex(where: { $0.contains(point) }), @@ -183,6 +274,21 @@ final class ViewerView: NSView, NSViewToolTipOwner { /// publish. private static func map(_ event: NSEvent) -> Int32 { let shift = event.modifierFlags.contains(.shift) + // Command normally never reaches here, because the Edit menu's key equivalents are matched + // first. Control is the fallback for a keyboard driving this over a remote session, and + // both are answered before the plain letters below: without that, ctrl+a fell through to + // A, which accepts. + if event.modifierFlags.contains(.command) || event.modifierFlags.contains(.control) { + switch event.charactersIgnoringModifiers?.lowercased() { + case "c": + return DEVIEW_KEY_COPY.value + case "a": + return DEVIEW_KEY_SELECT_ALL.value + default: + return DEVIEW_KEY_NONE.value + } + } + switch Int(event.keyCode) { case 126: return DEVIEW_KEY_SCROLL_UP.value 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 95e0d0a4..e7e62c4d 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 b713b8c0..6bcb719d 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 1a8f5843..11d27714 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 1a8f5843..11d27714 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.MenuOnConflictedEntry.verified.txt b/src/DiffEngineViewer.Tests/InlineScreenTests.MenuOnConflictedEntry.verified.txt index 23fc12a1..82726a00 100644 --- a/src/DiffEngineViewer.Tests/InlineScreenTests.MenuOnConflictedEntry.verified.txt +++ b/src/DiffEngineViewer.Tests/InlineScreenTests.MenuOnConflictedEntry.verified.txt @@ -4,14 +4,14 @@ | Pending (2) | received (net8.0) | expected | +----------------------+-----------------------------------+-----------------------------------+ | > * SampleTests.cs:> | 1 the quick | 1 the quick | -| +-------------------+ ~ 2 brown dog | ~ 2 brown fox | -| | Accept | 3 jumps over | 3 jumps over | -| | Show next variant | 4 the lazy | 4 the lazy | -| | Discard | 5 dog | 5 dog | -| | Open source file | | | -| +-------------------+ | | -| | | | -| | | | +| +------------------------+ 2 brown dog | ~ 2 brown fox | +| | Accept | 3 jumps over | 3 jumps over | +| | Show next variant | 4 the lazy | 4 the lazy | +| | Discard | 5 dog | 5 dog | +| | Open source file | | | +| | Copy received (net8.0) | | | +| | Copy expected | | | +| +------------------------+ | | | | | | | | | | | | | | diff --git a/src/DiffEngineViewer.Tests/InlineScreenTests.MenuOnEntry.verified.txt b/src/DiffEngineViewer.Tests/InlineScreenTests.MenuOnEntry.verified.txt index db697bf2..8d98e16c 100644 --- a/src/DiffEngineViewer.Tests/InlineScreenTests.MenuOnEntry.verified.txt +++ b/src/DiffEngineViewer.Tests/InlineScreenTests.MenuOnEntry.verified.txt @@ -8,6 +8,8 @@ | | Accept || 3 jumps over | 3 jumps over | | | Discard || 4 the lazy | 4 the lazy | | | Open source file || 5 dog | 5 dog | +| | Copy received || | | +| | Copy expected || | | | +------------------+| | | | | | | | | | | @@ -17,8 +19,6 @@ | | | | | | | | | | | | -| | | | -| | | | +----------------------+-----------------------------------+-----------------------------------+ | [Accept] [Discard] [Accept all] lines 1-5 of 5 | +----------------------------------------------------------------------------------------------+ \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/InlineScreenTests.MenuOnMove.verified.txt b/src/DiffEngineViewer.Tests/InlineScreenTests.MenuOnMove.verified.txt index 2cce604b..23e21f96 100644 --- a/src/DiffEngineViewer.Tests/InlineScreenTests.MenuOnMove.verified.txt +++ b/src/DiffEngineViewer.Tests/InlineScreenTests.MenuOnMove.verified.txt @@ -4,13 +4,13 @@ | Pending (2) | sample.received.txt | sample.verified.txt | +----------------------+-----------------------------------+-----------------------------------+ | > Sample.Test (txt) | 1 the quick | 1 the quick | -| +-----------------------+ 2 brown dog | ~ 2 brown fox | -| | Accept move | 3 jumps over | 3 jumps over | -| | Discard | 4 the lazy | 4 the lazy | -| | Open target directory | 5 dog | 5 dog | -| +-----------------------+ | | -| | | | -| | | | +| +--------------------------+ brown dog | ~ 2 brown fox | +| | Accept move | jumps over | 3 jumps over | +| | Discard | the lazy | 4 the lazy | +| | Open target directory | dog | 5 dog | +| | Copy sample.received.txt | | | +| | Copy sample.verified.txt | | | +| +--------------------------+ | | | | | | | | | | | | | | diff --git a/src/DiffEngineViewer.Tests/PixelTests.ContextMenu.Linux.verified.png b/src/DiffEngineViewer.Tests/PixelTests.ContextMenu.Linux.verified.png index 3a3724e6..7da61c36 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.Selection.Linux.verified.png b/src/DiffEngineViewer.Tests/PixelTests.Selection.Linux.verified.png new file mode 100644 index 00000000..9db521cf Binary files /dev/null and b/src/DiffEngineViewer.Tests/PixelTests.Selection.Linux.verified.png differ diff --git a/src/DiffEngineViewer.Tests/PixelTests.Selection.OSX.verified.png b/src/DiffEngineViewer.Tests/PixelTests.Selection.OSX.verified.png new file mode 100644 index 00000000..ed8e3860 Binary files /dev/null and b/src/DiffEngineViewer.Tests/PixelTests.Selection.OSX.verified.png differ diff --git a/src/DiffEngineViewer.Tests/PixelTests.cs b/src/DiffEngineViewer.Tests/PixelTests.cs index 36851aa2..f8676859 100644 --- a/src/DiffEngineViewer.Tests/PixelTests.cs +++ b/src/DiffEngineViewer.Tests/PixelTests.cs @@ -150,9 +150,21 @@ public Task Images() => public Task ContextMenu() => Capture(ViewerSession.OpenMenu(Fixtures.GroupedConflicted(), 5)); + /// + /// A selection dragged across three rows of the received pane. Mirrored in WindowsPixelTests + /// over the same range, because the highlight is the one part of a selection the ASCII + /// snapshots cannot describe - a character grid has no way to invert part of a line without + /// changing its width - so these baselines are what hold the three heads to one appearance. + /// [Test] [PixelTest] [NotInParallel(nameof(PixelTests), Order = 8)] + public Task Selection() => + Capture(ViewerSession.Drag(Fixtures.File(), PaneSide.Left, 1, 6, 3, 4)); + + [Test] + [PixelTest] + [NotInParallel(nameof(PixelTests), Order = 9)] public Task InlineAccepted() { var state = Fixtures.Inline( diff --git a/src/DiffEngineViewer.Tests/SelectionTests.AcrossRows.verified.txt b/src/DiffEngineViewer.Tests/SelectionTests.AcrossRows.verified.txt new file mode 100644 index 00000000..5b558c41 --- /dev/null +++ b/src/DiffEngineViewer.Tests/SelectionTests.AcrossRows.verified.txt @@ -0,0 +1,20 @@ +status: selected 3 lines, 19 characters + +Sample.received.txt + 1 the quick + 2 brown [dog] + 3 [jumps over] + 4 [the ]lazy + 5 dog + +Sample.verified.txt + 1 the quick + 2 brown fox + 3 jumps over + 4 the lazy + 5 dog + +copied: +dog +jumps over +the \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/SelectionTests.DraggedBackwards.verified.txt b/src/DiffEngineViewer.Tests/SelectionTests.DraggedBackwards.verified.txt new file mode 100644 index 00000000..5b558c41 --- /dev/null +++ b/src/DiffEngineViewer.Tests/SelectionTests.DraggedBackwards.verified.txt @@ -0,0 +1,20 @@ +status: selected 3 lines, 19 characters + +Sample.received.txt + 1 the quick + 2 brown [dog] + 3 [jumps over] + 4 [the ]lazy + 5 dog + +Sample.verified.txt + 1 the quick + 2 brown fox + 3 jumps over + 4 the lazy + 5 dog + +copied: +dog +jumps over +the \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/SelectionTests.OverAFillerRow.verified.txt b/src/DiffEngineViewer.Tests/SelectionTests.OverAFillerRow.verified.txt new file mode 100644 index 00000000..82972404 --- /dev/null +++ b/src/DiffEngineViewer.Tests/SelectionTests.OverAFillerRow.verified.txt @@ -0,0 +1,17 @@ +status: selected 2 lines, 8 characters + +Sample.received.txt + 1 one + 2 two + 3 three + 4 four + +Sample.verified.txt + 1 [one] + + + 2 [four] + +copied: +one +four \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/SelectionTests.PastTheEnds.verified.txt b/src/DiffEngineViewer.Tests/SelectionTests.PastTheEnds.verified.txt new file mode 100644 index 00000000..08d1ca19 --- /dev/null +++ b/src/DiffEngineViewer.Tests/SelectionTests.PastTheEnds.verified.txt @@ -0,0 +1,22 @@ +status: selected 5 lines, 34 characters + +Sample.received.txt + 1 the quick + 2 [brown dog] + 3 [jumps over] + 4 [the lazy] + 5 [dog] + +Sample.verified.txt + 1 the quick + 2 brown fox + 3 jumps over + 4 the lazy + 5 dog + +copied: + +brown dog +jumps over +the lazy +dog \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/SelectionTests.SelectAll.verified.txt b/src/DiffEngineViewer.Tests/SelectionTests.SelectAll.verified.txt new file mode 100644 index 00000000..fbd57ae7 --- /dev/null +++ b/src/DiffEngineViewer.Tests/SelectionTests.SelectAll.verified.txt @@ -0,0 +1,22 @@ +status: selected 5 lines, 43 characters + +Sample.received.txt + 1 [the quick] + 2 [brown dog] + 3 [jumps over] + 4 [the lazy] + 5 [dog] + +Sample.verified.txt + 1 the quick + 2 brown fox + 3 jumps over + 4 the lazy + 5 dog + +copied: +the quick +brown dog +jumps over +the lazy +dog \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/SelectionTests.SelectAllAfterClickingTheExpectedPane.verified.txt b/src/DiffEngineViewer.Tests/SelectionTests.SelectAllAfterClickingTheExpectedPane.verified.txt new file mode 100644 index 00000000..d510514e --- /dev/null +++ b/src/DiffEngineViewer.Tests/SelectionTests.SelectAllAfterClickingTheExpectedPane.verified.txt @@ -0,0 +1,22 @@ +status: selected 5 lines, 43 characters + +Sample.received.txt + 1 the quick + 2 brown dog + 3 jumps over + 4 the lazy + 5 dog + +Sample.verified.txt + 1 [the quick] + 2 [brown fox] + 3 [jumps over] + 4 [the lazy] + 5 [dog] + +copied: +the quick +brown fox +jumps over +the lazy +dog \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/SelectionTests.WithinOneRow.verified.txt b/src/DiffEngineViewer.Tests/SelectionTests.WithinOneRow.verified.txt new file mode 100644 index 00000000..94d581ac --- /dev/null +++ b/src/DiffEngineViewer.Tests/SelectionTests.WithinOneRow.verified.txt @@ -0,0 +1,18 @@ +status: selected 3 characters + +Sample.received.txt + 1 the quick + 2 brown dog + 3 jumps over + 4 the lazy + 5 dog + +Sample.verified.txt + 1 the quick + 2 brown [fox] + 3 jumps over + 4 the lazy + 5 dog + +copied: +fox \ No newline at end of file diff --git a/src/DiffEngineViewer.Tests/SelectionTests.cs b/src/DiffEngineViewer.Tests/SelectionTests.cs new file mode 100644 index 00000000..bebd99ea --- /dev/null +++ b/src/DiffEngineViewer.Tests/SelectionTests.cs @@ -0,0 +1,286 @@ +/// +/// Text selection: what a drag covers, what is highlighted, and what lands on the clipboard. +/// +/// Driven through rather than +/// directly, because a drag arrives as input fields and copying reaches a window, and those two +/// joints are the whole of what makes the feature work in a head. +/// +/// +/// The highlight is reported here rather than by , which draws a fixed +/// width character grid and cannot invert part of one without changing its width. That is why the +/// status line carries the model's universal statement about a selection: it is what the text +/// snapshots, and a renderer with no highlight, can still show. +/// +/// +public class SelectionTests +{ + [Test] + public Task AcrossRows() => + Verify(Report(Drag(Files(), PaneSide.Left, 1, 6, 3, 4))); + + /// + /// The same range dragged the other way. The ends are kept as anchor and focus rather than + /// ordered, so extending back past where the press landed keeps working; the ordering happens + /// where the range is read. + /// + [Test] + public Task DraggedBackwards() => + Verify(Report(Drag(Files(), PaneSide.Left, 3, 4, 1, 6))); + + [Test] + public Task WithinOneRow() => + Verify(Report(Drag(Files(), PaneSide.Right, 1, 6, 1, 9))); + + /// + /// A drag that ran off the bottom and off the ends of the lines. Heads report where the + /// pointer was without knowing how long a line is, so everything is pulled back inside the + /// text here. + /// + [Test] + public Task PastTheEnds() => + Verify(Report(Drag(Files(), PaneSide.Left, 0, 400, 90, 400))); + + /// + /// Filler rows keep the two panes aligned and are not content, so a selection that spans one + /// copies the lines either side of it rather than a blank line between them. + /// + [Test] + public Task OverAFillerRow() => + Verify( + Report( + Drag( + Files("one\ntwo\nthree\nfour", "one\nfour"), + PaneSide.Right, + 0, + 0, + 3, + 4))); + + [Test] + public Task SelectAll() => + Verify(Report(Key(Files(), CommandKind.SelectAll))); + + /// + /// Select all takes the side something is already selected in, so it reads as widening what + /// the reader was pointing at rather than as jumping to the other pane. + /// + [Test] + public Task SelectAllAfterClickingTheExpectedPane() => + Verify(Report(Key(Drag(Files(), PaneSide.Right, 0, 2, 0, 5), CommandKind.SelectAll))); + + [Test] + public async Task AClickWithNoDragBehindItClearsTheSelection() + { + var selected = Drag(Files(), PaneSide.Left, 1, 6, 3, 4); + await Assert.That(selected.LiveSelection).IsNotNull(); + + // Both ends in the same place, which is what a head reports for a press. + var clicked = Drag(selected, PaneSide.Left, 2, 3, 2, 3); + + await Assert.That(clicked.LiveSelection!.IsEmpty).IsTrue(); + await Assert.That(ScreenBuilder.Build(clicked).Status).IsEqualTo("lines 1-5 of 5"); + } + + /// + /// A selection names the entry it was dragged in, so moving to another one leaves it behind + /// rather than highlighting the same rows of a different file. One rule in one place, instead + /// of a clear-the-selection call on every transition. + /// + [Test] + public async Task ASelectionDoesNotFollowTheSelectedEntry() + { + var state = Drag( + Fixtures.Inline(Fixtures.Patch(), Fixtures.Patch("OtherTests.cs", 7)), + PaneSide.Left, + 1, + 0, + 1, + 5); + await Assert.That(state.LiveSelection).IsNotNull(); + + var stepped = ViewerSession.Apply(state, CommandKind.NextItem); + + await Assert.That(stepped.Selection).IsNotNull(); + await Assert.That(stepped.LiveSelection).IsNull(); + await Assert.That(ScreenBuilder.Build(stepped).Left.Rows.Any(_ => _.Selection.Length > 0)) + .IsFalse(); + } + + [Test] + public async Task CopyPutsTheSelectionOnTheClipboard() + { + var window = new Recorder(); + var state = Drag(Files(), PaneSide.Left, 1, 6, 3, 4); + + var copied = ViewerProgram.Apply(state, Input(CommandKind.Copy), link: null, window); + + await Assert.That(window.Copied).IsEqualTo("dog\njumps over\nthe "); + await Assert.That(copied.Message).IsEqualTo("Copied 3 lines from the selection."); + } + + [Test] + public async Task CopyWithNothingSelectedSaysSoAndWritesNothing() + { + var window = new Recorder(); + + var copied = ViewerProgram.Apply(Files(), Input(CommandKind.Copy), link: null, window); + + await Assert.That(window.Copied).IsNull(); + await Assert.That(copied.Message) + .IsEqualTo("Nothing is selected. Drag across a pane, or press ctrl+a."); + } + + [Test] + public async Task CopyASideTakesAllOfItWithoutTheFiller() + { + var window = new Recorder(); + var state = Files("one\ntwo\nthree\nfour", "one\nfour"); + + var copied = ViewerProgram.Apply(state, Input(CommandKind.CopyRight), link: null, window); + + await Assert.That(window.Copied).IsEqualTo("one\nfour"); + await Assert.That(copied.Message).IsEqualTo("Copied 2 lines from Sample.verified.txt."); + } + + /// + /// Copying reads what is on screen and writes it to this machine's clipboard, so it is never + /// forwarded to a queue owner - whose answer would be the text this process already holds. + /// + [Test] + public async Task CopyIsLocalEvenWhenDisplayingSomeoneElsesQueue() + { + var window = new Recorder(); + var state = Drag( + Fixtures.Attached(InlineQueue.Empty, Fixtures.Move()), + PaneSide.Left, + 0, + 0, + 0, + 3); + var link = new OwnerLink(new(state), port: 1); + + var copied = ViewerProgram.Apply(state, Input(CommandKind.Copy), link, window); + + await Assert.That(window.Copied).IsEqualTo("the"); + await Assert.That(copied.Message).IsEqualTo("Copied 1 line from the selection."); + } + + /// + /// Select all then copy, which is the keyboard's whole route to the clipboard and the reason + /// ctrl has to be answered before the plain letters: ctrl+a used to reach A, which accepts. + /// + [Test] + public async Task SelectAllThenCopyTakesTheWholeSide() + { + var window = new Recorder(); + var all = Key(Files(), CommandKind.SelectAll); + + ViewerProgram.Apply(all, Input(CommandKind.Copy), link: null, window); + + await Assert.That(window.Copied).IsEqualTo(Fixtures.Received); + } + + static SessionState Files(string left = Fixtures.Received, string right = Fixtures.Expected) => + Fixtures.File(left, right); + + static SessionState Drag( + SessionState state, + PaneSide side, + int anchorRow, + int anchorColumn, + int focusRow, + int focusColumn) => + ViewerProgram.Apply( + state, + Input() with + { + DragSide = (int) side, + DragAnchorRow = anchorRow, + DragAnchorColumn = anchorColumn, + DragFocusRow = focusRow, + DragFocusColumn = focusColumn + }, + link: null, + new Recorder()); + + static SessionState Key(SessionState state, CommandKind key) => + ViewerProgram.Apply(state, Input(key), link: null, new Recorder()); + + static ViewerInput Input(CommandKind key = CommandKind.None) => + new(key, -1, -1, 0, false, Fixtures.Columns, Fixtures.Rows); + + /// + /// The frame as far as a selection is concerned: what the status says about it, which + /// characters of which rows are highlighted, and what copying it would hand over. + /// + static string Report(SessionState state) + { + var screen = ScreenBuilder.Build(state); + var builder = new StringBuilder(); + builder.AppendLine($"status: {screen.Status}"); + Append(builder, screen.Left); + Append(builder, screen.Right); + builder.AppendLine(); + builder.AppendLine("copied:"); + builder.Append( + state.LiveSelection is { } selection + ? SelectionText.Of(selection, state.Current!) + : ""); + return builder.ToString(); + } + + /// + /// Brackets rather than a highlight, and only on the rows that have one. The width changes, + /// which is exactly why this cannot be what the grid renderer does. + /// + static void Append(StringBuilder builder, Pane pane) + { + builder.AppendLine(); + builder.AppendLine(pane.Header); + foreach (var row in pane.Rows) + { + var text = RowText.Flatten(row.Text); + if (row.Selection.Length > 0) + { + text = text + .Insert(row.Selection.Start + row.Selection.Length, "]") + .Insert(row.Selection.Start, "["); + } + + builder.AppendLine($" {row.LineNumber,4} {text}"); + } + } + + /// + /// A window that draws nothing and remembers what was copied, which is the only thing about a + /// head this needs. + /// + sealed class Recorder : IViewerWindow + { + public string? Copied { get; private set; } + + public bool Present(Screen screen) => + true; + + public ViewerInput Poll() => + default; + + public void SetHidden(bool hidden) + { + } + + public void Focus() + { + } + + public void SetClipboard(string text) => + Copied = text; + + public bool Capture(Screen screen, int width, int height, string pngPath) => + false; + + public void Dispose() + { + } + } +} diff --git a/src/DiffEngineViewer.Tests/ViewerSessionTests.cs b/src/DiffEngineViewer.Tests/ViewerSessionTests.cs index 72c38076..a5f44795 100644 --- a/src/DiffEngineViewer.Tests/ViewerSessionTests.cs +++ b/src/DiffEngineViewer.Tests/ViewerSessionTests.cs @@ -605,7 +605,7 @@ public async Task RightClickOnAnEntrySelectsAndOpensItsMenu() await Assert.That(open.Current!.Name).IsEqualTo("OtherTests.cs:7"); await Assert.That(open.Menu!.Items.Select(_ => _.Label)) - .IsEquivalentTo(["Accept", "Discard", "Open source file"]); + .IsEquivalentTo(["Accept", "Discard", "Open source file", "Copy received", "Copy expected"]); } [Test] @@ -614,7 +614,15 @@ public async Task AConflictedEntryMenuOffersTheVariant() var open = ViewerSession.OpenMenu(Conflicted(), 0); await Assert.That(open.Menu!.Items.Select(_ => _.Label)) - .IsEquivalentTo(["Accept", "Show next variant", "Discard", "Open source file"]); + .IsEquivalentTo( + [ + "Accept", + "Show next variant", + "Discard", + "Open source file", + "Copy received (net8.0)", + "Copy expected" + ]); } [Test] @@ -624,11 +632,26 @@ public async Task MoveAndDeleteMenusNameTheActs() var move = ViewerSession.OpenMenu(state, 0); await Assert.That(move.Menu!.Items.Select(_ => _.Label)) - .IsEquivalentTo(["Accept move", "Discard", "Open target directory"]); + .IsEquivalentTo( + [ + "Accept move", + "Discard", + "Open target directory", + "Copy sample.received.txt", + "Copy sample.verified.txt" + ]); var delete = ViewerSession.OpenMenu(state, 1); await Assert.That(delete.Menu!.Items.Select(_ => _.Label)) - .IsEquivalentTo(["Accept delete", "Discard", "Open directory"]); + .IsEquivalentTo( + // Only the file, because the left side of a delete is the state after accepting it, + // which is nothing to copy. + [ + "Accept delete", + "Discard", + "Open directory", + "Copy extra.verified.txt" + ]); } static SessionState TwoSolutions() => diff --git a/src/DiffEngineViewer.Windows.Tests/ContextMenuTests.Entry.verified.png b/src/DiffEngineViewer.Windows.Tests/ContextMenuTests.Entry.verified.png index 3bc8dad2..782cce37 100644 Binary files a/src/DiffEngineViewer.Windows.Tests/ContextMenuTests.Entry.verified.png and b/src/DiffEngineViewer.Windows.Tests/ContextMenuTests.Entry.verified.png differ diff --git a/src/DiffEngineViewer.Windows.Tests/ContextMenuTests.MoveEntry.verified.png b/src/DiffEngineViewer.Windows.Tests/ContextMenuTests.MoveEntry.verified.png index 53abb8ae..d02d0925 100644 Binary files a/src/DiffEngineViewer.Windows.Tests/ContextMenuTests.MoveEntry.verified.png and b/src/DiffEngineViewer.Windows.Tests/ContextMenuTests.MoveEntry.verified.png differ diff --git a/src/DiffEngineViewer.Windows.Tests/PaneHitTests.cs b/src/DiffEngineViewer.Windows.Tests/PaneHitTests.cs new file mode 100644 index 00000000..c5daffe2 --- /dev/null +++ b/src/DiffEngineViewer.Windows.Tests/PaneHitTests.cs @@ -0,0 +1,143 @@ +/// +/// The painter and the hit test have to be reading one layout, or a drag selects one run of +/// characters and colours another. Rather than restate the arithmetic, this draws a selection and +/// feeds the pixels it landed on back through the hit test. +/// +/// The pixel snapshots cannot catch that: they show where the highlight went, and say nothing +/// about where a click resolves to. This is the only test of the mapping in either direction. +/// +/// +[NotInParallel] +[TUnit.Core.Executors.STAThreadExecutor] +public class PaneHitTests +{ + const int width = 1100; + const int height = 700; + + /// + /// The grid the other Windows captures are pinned to. + /// + const int columns = 120; + + const int rows = 37; + + [Test] + public Task InTheReceivedPane() => + RoundTrip(PaneSide.Left); + + [Test] + public Task InTheExpectedPane() => + RoundTrip(PaneSide.Right); + + /// + /// Private rather than a parameterised test, because is internal and a + /// public test method cannot take one. + /// + static async Task RoundTrip(PaneSide side) + { + // One row, so the drawn run has exactly one top left corner to find. + var state = ViewerSession.Resize( + ViewerSession.Drag(Fixtures.File(), side, 1, 6, 1, 9), + columns, + rows); + + using var host = new Host(); + var bitmap = host.Draw(ScreenBuilder.Build(state)); + + var drawn = TopLeftOf(bitmap, Palette.Selection); + await Assert.That(drawn).IsNotNull(); + + var cell = host.Canvas.PaneCellAt(drawn!.Value); + + await Assert.That(cell).IsNotNull(); + await Assert.That(cell!.Value.Side).IsEqualTo(side); + await Assert.That(cell.Value.Row).IsEqualTo(1); + await Assert.That(cell.Value.Column).IsEqualTo(6); + } + + /// + /// The queue column belongs to the row hit test, not to this one, so a point in it is not a + /// pane cell however far down it is. + /// + [Test] + public async Task TheQueueColumnIsNotAPaneCell() + { + var state = ViewerSession.Resize(Fixtures.Inline(Fixtures.Patch()), columns, rows); + + using var host = new Host(); + host.Draw(ScreenBuilder.Build(state)); + + await Assert.That(host.Canvas.PaneCellAt(new(10, 200))).IsNull(); + } + + /// + /// The top left pixel of the first run drawn in , or null. Scanned + /// top down and then left to right, so it is the corner rather than any pixel of the run. + /// + static Point? TopLeftOf(Bitmap bitmap, Color colour) + { + for (var y = 0; y < bitmap.Height; y++) + { + for (var x = 0; x < bitmap.Width; x++) + { + if (bitmap.GetPixel(x, y).ToArgb() == colour.ToArgb()) + { + return new(x, y); + } + } + } + + return null; + } + + /// + /// A canvas in a real window, because measuring a cell and answering a paint both need a + /// handle. Parked off screen and never in the taskbar, so a run does not flash a window across + /// the middle of the display. + /// + sealed class Host : IDisposable + { + readonly Form form = new() + { + StartPosition = FormStartPosition.Manual, + Location = new(-2000, -2000), + ShowInTaskbar = false, + ClientSize = new(width, height) + }; + + public ViewerCanvas Canvas { get; } = new() + { + Dock = DockStyle.Fill + }; + + public Host() + { + form.Controls.Add(Canvas); + form.Show(); + } + + public Bitmap Draw(Screen screen) + { + Canvas.Draw(screen); + // Invalidate only marks dirty, and DrawToBitmap sends a paint message, so the paint + // has to have happened before the bitmap. + Canvas.Refresh(); + var bitmap = new Bitmap(Canvas.Width, Canvas.Height); + Canvas.DrawToBitmap(bitmap, new(0, 0, Canvas.Width, Canvas.Height)); + bitmaps.Add(bitmap); + return bitmap; + } + + readonly List bitmaps = []; + + public void Dispose() + { + foreach (var bitmap in bitmaps) + { + bitmap.Dispose(); + } + + form.Dispose(); + } + } +} diff --git a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.Selection.verified.png b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.Selection.verified.png new file mode 100644 index 00000000..8a25c7ef Binary files /dev/null and b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.Selection.verified.png differ diff --git a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.cs b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.cs index 59710b67..6a718c5d 100644 --- a/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.cs +++ b/src/DiffEngineViewer.Windows.Tests/WindowsPixelTests.cs @@ -106,6 +106,16 @@ public Task GroupedConflictedQueue() => public Task Images() => Capture(Fixtures.Images()); + /// + /// A selection dragged across three rows of the received pane. Mirrored in the native suite + /// over the same range, because the highlight is the one part of a selection the ASCII + /// snapshots cannot describe - a character grid has no way to invert part of a line without + /// changing its width - so these baselines are what hold the three heads to one appearance. + /// + [Test] + public Task Selection() => + Capture(ViewerSession.Drag(Fixtures.File(), PaneSide.Left, 1, 6, 3, 4)); + // No menu case here: this head shows a real popup, which is a top level window and so cannot // appear in a capture of the client area. ContextMenuTests renders the strip itself instead. diff --git a/src/DiffEngineViewer.Windows/FormsViewerWindow.cs b/src/DiffEngineViewer.Windows/FormsViewerWindow.cs index 2c0410f3..f0315edc 100644 --- a/src/DiffEngineViewer.Windows/FormsViewerWindow.cs +++ b/src/DiffEngineViewer.Windows/FormsViewerWindow.cs @@ -92,6 +92,22 @@ public void Focus() form.Raise(); } + /// + /// Best effort, the way revealing a file is. Another process can hold the clipboard open, and + /// failing to copy is not worth taking the reviewer's window down over. + /// + public void SetClipboard(string text) + { + try + { + Clipboard.SetText(text); + } + catch (ExternalException exception) + { + Console.Error.WriteLine($"Could not write to the clipboard: {exception.Message}"); + } + } + public bool Capture(Screen screen, int width, int height, string pngPath) { if (form.IsDisposed) diff --git a/src/DiffEngineViewer.Windows/Palette.cs b/src/DiffEngineViewer.Windows/Palette.cs index 7947106f..26c12bda 100644 --- a/src/DiffEngineViewer.Windows/Palette.cs +++ b/src/DiffEngineViewer.Windows/Palette.cs @@ -31,6 +31,14 @@ static class Palette /// public static readonly Color Selected = Color.FromArgb(38, 64, 90); + /// + /// Behind selected pane text. Brighter than the queue's row highlight so the two do not read + /// as the same thing, and dark enough that added green and removed red are still legible over + /// it - the text keeps its own colour when selected, because what kind of change a line is + /// does not stop mattering while it is being copied. + /// + public static readonly Color Selection = Color.FromArgb(55, 92, 130); + public static Color Foreground(RowKind kind) => kind switch { diff --git a/src/DiffEngineViewer.Windows/ViewerCanvas.cs b/src/DiffEngineViewer.Windows/ViewerCanvas.cs index 3473d377..254099b2 100644 --- a/src/DiffEngineViewer.Windows/ViewerCanvas.cs +++ b/src/DiffEngineViewer.Windows/ViewerCanvas.cs @@ -76,6 +76,26 @@ sealed class ViewerCanvas : Control bool dragging; + /// + /// Whether the left button is down over a pane, and where it went down. The side is fixed for + /// the life of the drag: a selection belongs to one pane, so crossing into the other extends + /// within the first rather than jumping. + /// + bool selecting; + + PaneSide selectSide; + + int selectAnchorRow; + + int selectAnchorColumn; + + /// + /// The drag as the input model wants it, or null. Held rather than raised as an event, because + /// it has to be reported on every frame the button is held - the model takes both ends each + /// time - and a press and release that both land between two drains still has to arrive. + /// + (PaneSide Side, int AnchorRow, int AnchorColumn, int FocusRow, int FocusColumn)? drag; + public ViewerCanvas() { SetStyle( @@ -108,6 +128,21 @@ public ViewerCanvas() public int ColumnCapacity => Math.Max(40, Width / Cell.Width); + /// + /// The drag in progress, for . Cleared on the read after the + /// button came up, so the last position is reported once more and then stops. + /// + public (PaneSide Side, int AnchorRow, int AnchorColumn, int FocusRow, int FocusColumn)? TakeDrag() + { + var taken = drag; + if (!selecting) + { + drag = null; + } + + return taken; + } + public void Draw(Screen value) { screen = value; @@ -173,6 +208,78 @@ screen is not null && screen.Queue.Count > 0 && Math.Abs(x - SplitterX) <= grab; + /// + /// Where the two panes ended up. Read by the paint and by the hit testing, which is the point: + /// a highlight drawn from one set of numbers and a drag resolved from another would select one + /// run of characters and colour a different one. + /// + (int Left, int Half, int Width) Panes() + { + var queue = screen is { Queue.Count: > 0 } ? QueueWidth : 0; + var left = queue > 0 ? padding + queue + gap : padding; + var width = Math.Max(2 * Cell.Width, Width - padding - left); + return (left, width / 2, width); + } + + /// + /// Where a pane's row text starts, which is its column plus the gutter. + /// + int TextLeft(PaneSide side) + { + var panes = Panes(); + return (side == PaneSide.Left ? panes.Left : panes.Left + panes.Half) + gutterCells * Cell.Width; + } + + /// + /// The pane cell under a point, or null when the point is not over one. Rows are rows of the + /// whole side rather than of the visible slice, since that is what a selection is anchored in. + /// + /// + /// Internal so PaneHitTests can round-trip a drawn highlight back through it, which is the + /// only check that the painter and the hit test are reading the same layout. + /// + internal (PaneSide Side, int Row, int Column)? PaneCellAt(Point point) + { + if (screen is null) + { + return null; + } + + var panes = Panes(); + if (point.X < panes.Left || + point.Y < BodyTop) + { + return null; + } + + var row = (point.Y - BodyTop) / Cell.Height; + if (row >= BodyCapacity) + { + return null; + } + + var side = point.X < panes.Left + panes.Half ? PaneSide.Left : PaneSide.Right; + return (side, ScrollTop(side) + row, ColumnAt(point.X, side)); + } + + int ScrollTop(PaneSide side) => + side == PaneSide.Left ? screen!.Left.ScrollTop : screen!.Right.ScrollTop; + + /// + /// Rounded to the nearest boundary between characters rather than truncated to the one under + /// the pointer, because a selection ends between two characters and the half a reader is + /// pointing at is the one they mean. + /// + int ColumnAt(int x, PaneSide side) => + Math.Max(0, (x - TextLeft(side) + Cell.Width / 2) / Cell.Width); + + /// + /// The body row a point is on, clamped into the body. Used while dragging, where a pointer + /// above or below the rows means the first or last of them rather than nothing. + /// + int DraggedRow(int y) => + Math.Clamp((y - BodyTop) / Cell.Height, 0, Math.Max(0, BodyCapacity - 1)); + protected override void OnPaint(PaintEventArgs e) { var graphics = e.Graphics; @@ -186,9 +293,7 @@ protected override void OnPaint(PaintEventArgs e) var lineHeight = Cell.Height; var hasQueue = screen.Queue.Count > 0; 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; + var (panesLeft, half, panesWidth) = Panes(); DrawTitle(graphics, lineHeight); @@ -374,6 +479,21 @@ void DrawRow(Graphics graphics, Pane pane, int index, Rectangle bounds) } var gutter = gutterCells * Cell.Width; + // Behind the text rather than over it, and the text keeps its own colour: what kind of + // change a line is has to survive being selected. + if (row.Selection.Length > 0) + { + graphics.FillRectangle( + Painter.Brush(Palette.Selection), + Rectangle.Intersect( + new( + bounds.X + gutter + row.Selection.Start * Cell.Width, + bounds.Y, + row.Selection.Length * Cell.Width, + bounds.Height), + bounds)); + } + Painter.Draw( graphics, $"{Palette.Marker(row.Kind)} {row.LineNumber,4}", @@ -404,8 +524,7 @@ static RectangleF Cellular(int left, int top, int width, int height) => protected override void OnMouseDown(MouseEventArgs e) { base.OnMouseDown(e); - if (screen is null || - screen.Queue.Count == 0) + if (screen is null) { return; } @@ -421,18 +540,34 @@ protected override void OnMouseDown(MouseEventArgs e) } var index = QueueRowAt(e.Location); - if (index < 0) + if (index >= 0) { + if (e.Button == MouseButtons.Right) + { + QueueItemRightClicked?.Invoke(index, e.Location); + return; + } + + QueueItemClicked?.Invoke(index); return; } - if (e.Button == MouseButtons.Right) + // Not gated on there being a queue: file mode has two panes and no column, and its text is + // as worth copying as anything else. + if (e.Button != MouseButtons.Left || + PaneCellAt(e.Location) is not { } cell) { - QueueItemRightClicked?.Invoke(index, e.Location); return; } - QueueItemClicked?.Invoke(index); + selecting = true; + Capture = true; + selectSide = cell.Side; + selectAnchorRow = cell.Row; + selectAnchorColumn = cell.Column; + // Both ends on the press, so a click with no drag behind it reports an empty selection, + // which is what clears the previous one. + drag = (cell.Side, cell.Row, cell.Column, cell.Row, cell.Column); } /// @@ -459,6 +594,19 @@ int QueueRowAt(Point point) protected override void OnMouseMove(MouseEventArgs e) { base.OnMouseMove(e); + if (selecting) + { + // Against the side the press landed in, whatever the pointer has wandered over since: + // a selection is one pane's, and the other pane's rows are a different document. + drag = ( + selectSide, + selectAnchorRow, + selectAnchorColumn, + ScrollTop(selectSide) + DraggedRow(e.Y), + ColumnAt(e.X, selectSide)); + return; + } + if (dragging) { // Clamped as a width, then held as cells, so the drag stops where it always stopped @@ -474,8 +622,13 @@ protected override void OnMouseMove(MouseEventArgs e) } // 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; + // pixel the mouse moves over the canvas. The beam over a pane is the only thing that says + // the text there can be selected at all. + var wanted = OverSplitter(e.X) + ? Cursors.VSplit + : PaneCellAt(e.Location) is null + ? Cursors.Default + : Cursors.IBeam; if (Cursor != wanted) { Cursor = wanted; @@ -500,6 +653,14 @@ protected override void OnMouseUp(MouseEventArgs e) dragging = false; Capture = false; } + + if (selecting) + { + // The drag itself is left for one more read, so a press and release between two frames + // still reports the click that cleared the selection. + selecting = false; + Capture = false; + } } /// diff --git a/src/DiffEngineViewer.Windows/ViewerForm.cs b/src/DiffEngineViewer.Windows/ViewerForm.cs index 26ebf2f2..0dfe5824 100644 --- a/src/DiffEngineViewer.Windows/ViewerForm.cs +++ b/src/DiffEngineViewer.Windows/ViewerForm.cs @@ -303,6 +303,7 @@ void ApplyButtons(Screen screen) public ViewerInput Drain() { + var drag = canvas.TakeDrag(); var input = new ViewerInput( Key: key, ClickedButton: clickedButton, @@ -316,7 +317,12 @@ public ViewerInput Drain() RightClickedQueueItem: rightClickedQueueItem, ClickedMenuItem: clickedMenuItem, MenuClosed: menuClosed, - ScrollTo: scrollTo); + ScrollTo: scrollTo, + DragSide: drag is null ? -1 : (int) drag.Value.Side, + DragAnchorRow: drag?.AnchorRow ?? 0, + DragAnchorColumn: drag?.AnchorColumn ?? 0, + DragFocusRow: drag?.FocusRow ?? 0, + DragFocusColumn: drag?.FocusColumn ?? 0); key = CommandKind.None; clickedButton = -1; @@ -390,7 +396,21 @@ protected override bool ProcessCmdKey(ref Message message, Keys keyData) static CommandKind Map(Keys keyData) { var shift = (keyData & Keys.Shift) == Keys.Shift; - return (keyData & Keys.KeyCode) switch + var code = keyData & Keys.KeyCode; + // Answered on its own rather than folded into the switch, which reads only the key code: + // ctrl+a is select all, and without this it was accept - the modifier the whole point of + // the chord went straight through. + if ((keyData & Keys.Control) == Keys.Control) + { + return code switch + { + Keys.C => CommandKind.Copy, + Keys.A => CommandKind.SelectAll, + _ => CommandKind.None + }; + } + + return code switch { Keys.Up => CommandKind.ScrollUp, Keys.Down => CommandKind.ScrollDown, diff --git a/src/DiffEngineViewer/CommandKind.cs b/src/DiffEngineViewer/CommandKind.cs index 49abb650..53529469 100644 --- a/src/DiffEngineViewer/CommandKind.cs +++ b/src/DiffEngineViewer/CommandKind.cs @@ -43,6 +43,27 @@ enum CommandKind /// ToggleGroup, + /// + /// Select every line of one pane, the side of whatever is already selected. View only, and + /// applied locally even when the queue belongs to someone else: what is on screen is this + /// process's to read however it likes. + /// + SelectAll, + + /// + /// Put the selected pane text on the clipboard. Nothing selected copies nothing and says so, + /// rather than guessing at a range the reader did not ask for. + /// + Copy, + + /// + /// Both sides whole, for a reader who wants the text rather than a range of it. Filler rows + /// are left out, so what lands on the clipboard is the file's lines rather than the diff's + /// padding. + /// + CopyLeft, + CopyRight, + /// /// Show the current entry's file in the platform's file manager. Local IO even when the queue /// belongs to someone else, because the protocol never leaves the machine. diff --git a/src/DiffEngineViewer/IViewerWindow.cs b/src/DiffEngineViewer/IViewerWindow.cs index 08970f6e..8b23dd68 100644 --- a/src/DiffEngineViewer/IViewerWindow.cs +++ b/src/DiffEngineViewer/IViewerWindow.cs @@ -22,6 +22,17 @@ interface IViewerWindow : IDisposable void SetHidden(bool hidden); + /// + /// Puts text on the system clipboard. + /// + /// Here rather than in with the rest of the IO, because a + /// clipboard belongs to a toolkit the way a window does: WinForms, AppKit and GLFW each own + /// one, and the alternative was shelling out to clip, pbcopy and whichever of + /// xclip or wl-copy happened to be installed. + /// + /// + void SetClipboard(string text); + void Focus(); /// diff --git a/src/DiffEngineViewer/MenuState.cs b/src/DiffEngineViewer/MenuState.cs index 812008d8..e9800f4e 100644 --- a/src/DiffEngineViewer/MenuState.cs +++ b/src/DiffEngineViewer/MenuState.cs @@ -26,7 +26,7 @@ record MenuState(int Row, IReadOnlyList Items, IReadOnlyList Memb /// static class ContextMenu { - public static IReadOnlyList ForEntry(QueueEntry entry) + public static IReadOnlyList ForEntry(QueueEntry entry, bool hasSelection) { var items = new List(); switch (entry.Kind) @@ -53,9 +53,32 @@ public static IReadOnlyList ForEntry(QueueEntry entry) break; } + // Last, and named after the panes rather than after the sides, so the menu reads as the + // headers above the text it copies. "Copy selection" only when there is one: an item that + // would copy nothing is worse than no item. + if (hasSelection) + { + items.Add(new("Copy selection", CommandKind.Copy)); + } + + AddCopy(items, entry, PaneSide.Left, CommandKind.CopyLeft); + AddCopy(items, entry, PaneSide.Right, CommandKind.CopyRight); return items; } + /// + /// A side, unless copying it would copy nothing. A pending delete's left side is the state + /// after accepting, which is no file at all, and the expected side of a brand new snapshot has + /// nothing in it yet - and an item that reports "nothing to copy" is worse than no item. + /// + static void AddCopy(List items, QueueEntry entry, PaneSide side, CommandKind kind) + { + if (SelectionText.All(entry, side).Length > 0) + { + items.Add(new($"Copy {SelectionText.Header(entry, side)}", kind)); + } + } + /// /// Folding leads, above the two bulk commands. It is the only item here that changes nothing /// but the view, and it is the one reached most often, so it takes the position nearest the diff --git a/src/DiffEngineViewer/Model/Row.cs b/src/DiffEngineViewer/Model/Row.cs index 3792d1cc..baa905f9 100644 --- a/src/DiffEngineViewer/Model/Row.cs +++ b/src/DiffEngineViewer/Model/Row.cs @@ -2,4 +2,20 @@ /// One rendered line in a diff pane. is null for /// rows. /// -record Row(int? LineNumber, RowKind Kind, string Text); +record Row(int? LineNumber, RowKind Kind, string Text) +{ + /// + /// What of this row the reader has selected. Empty on a row of a + /// , which is the document rather than the frame, and filled in by + /// for the visible slice. + /// + /// Honoured by the three pixel heads and ignored by , which draws a + /// character grid and has no way to invert part of one without changing its width. The same + /// bargain makes: what the model universally states about a selection + /// goes in the status line, where every renderer draws it and the text snapshots show it, and + /// the highlight is the enrichment on top. So a head can be missing the highlight and still + /// show a screen that is smaller rather than wrong. + /// + /// + public SelectionSpan Selection { get; init; } +} diff --git a/src/DiffEngineViewer/Model/SelectionSpan.cs b/src/DiffEngineViewer/Model/SelectionSpan.cs new file mode 100644 index 00000000..5d341652 --- /dev/null +++ b/src/DiffEngineViewer/Model/SelectionSpan.cs @@ -0,0 +1,10 @@ +/// +/// A run of selected characters inside one , measured in characters of +/// ed text so a renderer can turn it into cells by multiplying. +/// +/// is zero when nothing on the row is selected, which is every row of almost +/// every frame. There is no separate "none", because a zero length run is already nothing to draw +/// and a nullable would have to be unwrapped by three heads and flattened across the ABI anyway. +/// +/// +readonly record struct SelectionSpan(int Start, int Length); diff --git a/src/DiffEngineViewer/Native/Deview.cs b/src/DiffEngineViewer/Native/Deview.cs index 0d9ef066..bb5bead5 100644 --- a/src/DiffEngineViewer/Native/Deview.cs +++ b/src/DiffEngineViewer/Native/Deview.cs @@ -11,7 +11,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 = 7; + public const int ExpectedVersion = 8; [LibraryImport(library, EntryPoint = "deview_version")] public static partial int Version(); @@ -38,6 +38,9 @@ public static partial int Init( [LibraryImport(library, EntryPoint = "deview_set_hidden")] public static partial void SetHidden(int hidden); + [LibraryImport(library, EntryPoint = "deview_set_clipboard", StringMarshalling = StringMarshalling.Utf8)] + public static partial void SetClipboard(string text); + [LibraryImport(library, EntryPoint = "deview_focus")] public static partial void Focus(); diff --git a/src/DiffEngineViewer/Native/DeviewStructs.cs b/src/DiffEngineViewer/Native/DeviewStructs.cs index 1b7a963d..023b61b3 100644 --- a/src/DiffEngineViewer/Native/DeviewStructs.cs +++ b/src/DiffEngineViewer/Native/DeviewStructs.cs @@ -20,6 +20,14 @@ struct DeviewRow public int TextOffset; public int TextLength; + + /// + /// , in characters of the flattened text. Zero length on a row with + /// nothing selected, which is every row of almost every frame. + /// + public int SelectStart; + + public int SelectLength; } [StructLayout(LayoutKind.Sequential)] @@ -118,6 +126,18 @@ struct DeviewInput public int CloseRequested; public int Columns; public int Rows; + + /// + /// A drag across a pane: 0 left, 1 right, -1 when there is none, and its two ends in rows of + /// the whole side and characters of the row. Unclamped, because the shim does not hold the + /// text. + /// + public int DragSide; + + public int DragAnchorRow; + public int DragAnchorColumn; + public int DragFocusRow; + public int DragFocusColumn; } [Flags] @@ -162,5 +182,7 @@ enum DeviewKey Discard = 12, AcceptAll = 13, Quit = 14, - NextVariant = 15 + NextVariant = 15, + Copy = 16, + SelectAll = 17 } diff --git a/src/DiffEngineViewer/Native/NativeViewerWindow.cs b/src/DiffEngineViewer/Native/NativeViewerWindow.cs index e8185565..26da667e 100644 --- a/src/DiffEngineViewer/Native/NativeViewerWindow.cs +++ b/src/DiffEngineViewer/Native/NativeViewerWindow.cs @@ -89,12 +89,20 @@ public unsafe ViewerInput Poll() RightClickedQueueItem: input.RightClickedQueueItem, ClickedMenuItem: input.ClickedMenuItem, MenuClosed: input.MenuClosed != 0, - ScrollTo: input.ScrollTo); + ScrollTo: input.ScrollTo, + DragSide: input.DragSide, + DragAnchorRow: input.DragAnchorRow, + DragAnchorColumn: input.DragAnchorColumn, + DragFocusRow: input.DragFocusRow, + DragFocusColumn: input.DragFocusColumn); } public void SetHidden(bool hidden) => Deview.SetHidden(hidden ? 1 : 0); + public void SetClipboard(string text) => + Deview.SetClipboard(text); + public void Focus() => Deview.Focus(); @@ -120,6 +128,8 @@ static CommandKind Key(int key) => DeviewKey.AcceptAll => CommandKind.AcceptAll, DeviewKey.Quit => CommandKind.Quit, DeviewKey.NextVariant => CommandKind.NextVariant, + DeviewKey.Copy => CommandKind.Copy, + DeviewKey.SelectAll => CommandKind.SelectAll, _ => CommandKind.None }; diff --git a/src/DiffEngineViewer/Native/ScreenPayload.cs b/src/DiffEngineViewer/Native/ScreenPayload.cs index 48d9d5a2..6efd34d2 100644 --- a/src/DiffEngineViewer/Native/ScreenPayload.cs +++ b/src/DiffEngineViewer/Native/ScreenPayload.cs @@ -170,7 +170,9 @@ DeviewPane AddPane(Pane pane) Kind = (int) row.Kind, LineNumber = row.LineNumber ?? -1, TextOffset = textOffset, - TextLength = textLength + TextLength = textLength, + SelectStart = row.Selection.Start, + SelectLength = row.Selection.Length }); } diff --git a/src/DiffEngineViewer/ScreenBuilder.cs b/src/DiffEngineViewer/ScreenBuilder.cs index 8ac43630..c8c426d5 100644 --- a/src/DiffEngineViewer/ScreenBuilder.cs +++ b/src/DiffEngineViewer/ScreenBuilder.cs @@ -16,18 +16,23 @@ public static Screen Build(SessionState state) { var body = BodyRows(state); var current = state.Current; + var selection = state.LiveSelection; var left = BuildPane( current?.LeftHeader ?? "received", current?.LeftRows ?? [], state.ScrollTop, body, - current?.LeftImage); + current?.LeftImage, + selection, + PaneSide.Left); var right = BuildPane( current?.RightHeader ?? "expected", current?.RightRows ?? [], state.ScrollTop, body, - current?.RightImage); + current?.RightImage, + selection, + PaneSide.Right); var queue = BuildQueue(state, body, out var top); return new( @@ -71,13 +76,20 @@ static Pane BuildPane( IReadOnlyList rows, int scrollTop, int body, - ImageFile? image) + ImageFile? image, + TextSelection? selection, + PaneSide side) { var end = Math.Min(scrollTop + body, rows.Count); var visible = new List(Math.Max(0, end - scrollTop)); for (var index = Math.Max(0, scrollTop); index < end; index++) { - visible.Add(rows[index]); + var row = rows[index]; + // Attached to the visible slice rather than carried beside it, so a head draws a row + // and its highlight from one thing and the frame comparison that decides whether to + // repaint already covers both. + var span = SelectionText.Span(selection, side, index, row.Text); + visible.Add(span.Length == 0 ? row : row with { Selection = span }); } return new(header, visible, scrollTop, rows.Count, BuildImage(image)); @@ -179,6 +191,15 @@ static string BuildStatus(SessionState state, QueueEntry? current, int body) return "nothing pending"; } + // Above the warning and the line count, because a selection is what the reader is doing + // right now and both of those are still true the moment it goes. This is also the whole + // of what a renderer with no way to invert text can say about one, which is why it is + // stated here rather than left to the highlight. + if (state.LiveSelection is { IsEmpty: false } selection) + { + return SelectionText.Summary(selection, current); + } + if (current.Warning is not null) { return current.Warning; diff --git a/src/DiffEngineViewer/SelectionText.cs b/src/DiffEngineViewer/SelectionText.cs new file mode 100644 index 00000000..5fdd1f78 --- /dev/null +++ b/src/DiffEngineViewer/SelectionText.cs @@ -0,0 +1,131 @@ +/// +/// What a covers, as spans to draw and as text to copy. +/// +/// Everything here reads the flattened row text rather than the file's own. A tab is four cells on +/// screen and one character in the file, and the columns a selection carries were pointed at on +/// screen, so measuring anything else would highlight one run and copy another. +/// +/// +static class SelectionText +{ + public static IReadOnlyList Rows(QueueEntry entry, PaneSide side) => + side == PaneSide.Left ? entry.LeftRows : entry.RightRows; + + public static string Header(QueueEntry entry, PaneSide side) => + side == PaneSide.Left ? entry.LeftHeader : entry.RightHeader; + + /// + /// The ends put in reading order and pulled back inside the rows that exist, so everything + /// downstream can index without checking. + /// + public static TextSelection Clamp(TextSelection selection, QueueEntry entry) + { + var rows = Rows(entry, selection.Side); + return selection with + { + AnchorRow = ClampRow(selection.AnchorRow, rows), + AnchorColumn = ClampColumn(selection.AnchorRow, selection.AnchorColumn, rows), + FocusRow = ClampRow(selection.FocusRow, rows), + FocusColumn = ClampColumn(selection.FocusRow, selection.FocusColumn, rows) + }; + } + + /// + /// What of one row is selected, for a row of the visible slice. Empty for the other side and + /// for a row outside the selection, which is most of them. + /// + public static SelectionSpan Span(TextSelection? selection, PaneSide side, int row, string text) + { + if (selection is not { IsEmpty: false } range || + range.Side != side) + { + return default; + } + + var (startRow, startColumn) = range.Start; + var (endRow, endColumn) = range.End; + if (row < startRow || + row > endRow) + { + return default; + } + + var length = RowText.Flatten(text).Length; + var from = row == startRow ? Math.Min(startColumn, length) : 0; + var to = row == endRow ? Math.Min(endColumn, length) : length; + return to <= from ? default : new(from, to - from); + } + + /// + /// The selected text, ready for the clipboard. Empty when the selection covers nothing. + /// + /// Filler rows are left out rather than copied as blank lines. They are the padding that keeps + /// the two panes aligned, not content, so pasting them back would put lines into a file that + /// were never in one. + /// + /// + public static string Of(TextSelection selection, QueueEntry entry) + { + var rows = Rows(entry, selection.Side); + var (startRow, _) = selection.Start; + var (endRow, _) = selection.End; + var lines = new List(); + for (var index = Math.Max(0, startRow); index <= endRow && index < rows.Count; index++) + { + var row = rows[index]; + if (row.Kind == RowKind.Filler) + { + continue; + } + + var text = RowText.Flatten(row.Text); + var span = Span(selection, selection.Side, index, row.Text); + lines.Add(span.Length == 0 ? "" : text.Substring(span.Start, span.Length)); + } + + return string.Join("\n", lines); + } + + /// + /// One whole side, which is what the copy commands that name a pane hand over. Filler rows are + /// dropped for the same reason they are dropped from a selection. + /// + public static string All(QueueEntry entry, PaneSide side) => + string.Join( + "\n", + Rows(entry, side) + .Where(_ => _.Kind != RowKind.Filler) + .Select(_ => RowText.Flatten(_.Text))); + + /// + /// What the status line says while something is selected. The universal statement about a + /// selection: the heads that can draw a highlight also draw this, and the one that cannot + /// still says a selection exists and how much of one. + /// + public static string Summary(TextSelection selection, QueueEntry entry) + { + var text = Of(selection, entry); + if (text.Length == 0) + { + return "nothing selected"; + } + + var lines = text.Count(_ => _ == '\n') + 1; + var characters = $"{text.Length} character{(text.Length == 1 ? "" : "s")}"; + return lines == 1 ? $"selected {characters}" : $"selected {lines} lines, {characters}"; + } + + static int ClampRow(int row, IReadOnlyList rows) => + Math.Clamp(row, 0, Math.Max(0, rows.Count - 1)); + + static int ClampColumn(int row, int column, IReadOnlyList rows) + { + if (rows.Count == 0) + { + return 0; + } + + var text = RowText.Flatten(rows[ClampRow(row, rows)].Text); + return Math.Clamp(column, 0, text.Length); + } +} diff --git a/src/DiffEngineViewer/SessionState.cs b/src/DiffEngineViewer/SessionState.cs index 947a9100..61d4a343 100644 --- a/src/DiffEngineViewer/SessionState.cs +++ b/src/DiffEngineViewer/SessionState.cs @@ -37,6 +37,21 @@ record SessionState( /// public IReadOnlySet Collapsed { get; init; } = new HashSet(); + /// + /// The pane text the reader has selected, or null. Carried here rather than in the frame + /// because a drag survives scrolling, resizing and anything else that rebuilds a + /// , which is every frame. + /// + public TextSelection? Selection { get; init; } + + /// + /// The selection, but only while it still describes what is on screen. Everything that reads + /// one goes through this, so a stale selection needs no clearing: the entry it named is gone, + /// so it stops existing. + /// + public TextSelection? LiveSelection => + Selection is { } selection && selection.Describes(Current) ? selection : null; + public QueueEntry? Current => Selected >= 0 && Selected < Queue.Count ? Queue[Selected] : null; diff --git a/src/DiffEngineViewer/TextSelection.cs b/src/DiffEngineViewer/TextSelection.cs new file mode 100644 index 00000000..4517920e --- /dev/null +++ b/src/DiffEngineViewer/TextSelection.cs @@ -0,0 +1,62 @@ +/// +/// Which pane something is in. The two sides are drawn from one , so a +/// selection has to say which of them it is against. +/// +enum PaneSide +{ + Left, + Right +} + +/// +/// A range of pane text the reader has selected, held as the two ends of the drag rather than as +/// an ordered pair, so extending a selection backwards past its own start keeps working. +/// +/// Rows are indexes into the whole side, not into the visible slice: a drag that continues while +/// the wheel scrolls has to mean the same thing before and after. Columns are characters of the +/// row's flattened text, which is what is on screen and therefore what was pointed at. +/// +/// +/// and are the entry this describes. Anything +/// that changes what is being read - selecting another entry, cycling a variant, a queue that +/// rebuilt underneath - leaves a selection that no longer matches, and a selection that does not +/// match the current entry is not shown or copied. That is one rule in one place rather than a +/// clear-the-selection call on every transition, one of which would eventually be missed. +/// +/// +record TextSelection( + string Key, + int Variant, + PaneSide Side, + int AnchorRow, + int AnchorColumn, + int FocusRow, + int FocusColumn) +{ + /// + /// A press with no drag behind it. Nothing is highlighted and nothing would be copied, so a + /// click in a pane reads as clearing the selection. + /// + public bool IsEmpty => + AnchorRow == FocusRow && + AnchorColumn == FocusColumn; + + public (int Row, int Column) Start => + Backwards ? (FocusRow, FocusColumn) : (AnchorRow, AnchorColumn); + + public (int Row, int Column) End => + Backwards ? (AnchorRow, AnchorColumn) : (FocusRow, FocusColumn); + + bool Backwards => + FocusRow < AnchorRow || + (FocusRow == AnchorRow && FocusColumn < AnchorColumn); + + /// + /// Whether this describes what is currently on screen. False for a selection left behind by + /// an entry that has been accepted, discarded or cycled away from. + /// + public bool Describes(QueueEntry? entry) => + entry is not null && + entry.Key == Key && + entry.SelectedVariant == Variant; +} diff --git a/src/DiffEngineViewer/ViewerInput.cs b/src/DiffEngineViewer/ViewerInput.cs index 673cf889..e4c8f1d8 100644 --- a/src/DiffEngineViewer/ViewerInput.cs +++ b/src/DiffEngineViewer/ViewerInput.cs @@ -18,4 +18,15 @@ readonly record struct ViewerInput( // dismiss. bool MenuClosed = false, // An absolute row to scroll to, or -1. What a scrollbar thumb reports. - int ScrollTo = -1); + int ScrollTo = -1, + // The pane a drag is in progress in, or -1 for the frames - almost all of them - with no + // button held down over one. Both ends are reported together for as long as it is held, and + // then simply stop being reported, so a whole press-drag-release inside one frame still + // arrives whole and a release has nothing left to say. + int DragSide = -1, + // Rows of the whole side rather than of the visible slice: a head knows the scroll top it drew + // the press with, and only it can resolve a drag that spans a wheel notch. + int DragAnchorRow = 0, + int DragAnchorColumn = 0, + int DragFocusRow = 0, + int DragFocusColumn = 0); diff --git a/src/DiffEngineViewer/ViewerProgram.cs b/src/DiffEngineViewer/ViewerProgram.cs index 1f2ea096..6c347929 100644 --- a/src/DiffEngineViewer/ViewerProgram.cs +++ b/src/DiffEngineViewer/ViewerProgram.cs @@ -309,7 +309,7 @@ static void Loop( } var input = window.Poll(); - host.Mutate(_ => Apply(_, input, link)); + host.Mutate(_ => Apply(_, input, link, window)); // Q, Escape and the Close menu item arrive as a state flag, consumed here into the // same decision as the window's own close button. Routed rather than exited, because @@ -344,7 +344,11 @@ static void Loop( } } - static SessionState Apply(SessionState state, ViewerInput input, OwnerLink? link) + /// + /// One frame of input against one state. Internal so SelectionTests can drive a drag and a + /// copy the way a head does, since the clipboard and the drag are only connected here. + /// + internal static SessionState Apply(SessionState state, ViewerInput input, OwnerLink? link, IViewerWindow window) { state = ViewerSession.Resize(state, input.Columns, input.Rows); @@ -363,7 +367,7 @@ static SessionState Apply(SessionState state, ViewerInput input, OwnerLink? link if (state.Menu is { } open && input.ClickedMenuItem < open.Items.Count) { - state = Dispatch(state, open.Items[input.ClickedMenuItem].Kind, link); + state = Dispatch(state, open.Items[input.ClickedMenuItem].Kind, link, window); } } else if (input.RightClickedQueueItem >= 0) @@ -392,6 +396,24 @@ static SessionState Apply(SessionState state, ViewerInput input, OwnerLink? link } } + // After the click chain, deliberately. A drag closes the menu like every other input, and + // the head that draws its own menu reports a click on one as landing wherever the menu is + // floating - which is over a pane. Resolving the drag first would then close the menu + // before the branch above could look up which item was chosen, and swallow the command. + // + // Order against the scroll does not matter: both ends arrive in rows of the whole side, + // which is what a scroll top is subtracted from rather than added to. + if (input.DragSide >= 0) + { + state = ViewerSession.Drag( + state, + input.DragSide == 0 ? PaneSide.Left : PaneSide.Right, + input.DragAnchorRow, + input.DragAnchorColumn, + input.DragFocusRow, + input.DragFocusColumn); + } + // After the click chain above, deliberately: that branch needs the menu still open to // resolve which item was chosen, so clearing first would swallow the command. And not when // a right-click opened another menu in the same frame, which is the dismissal's successor @@ -416,14 +438,14 @@ static SessionState Apply(SessionState state, ViewerInput input, OwnerLink? link var button = buttons[input.ClickedButton]; if (button.Enabled) { - state = Dispatch(state, button.Command, link); + state = Dispatch(state, button.Command, link, window); } } } if (input.Key != CommandKind.None) { - state = Dispatch(state, input.Key, link); + state = Dispatch(state, input.Key, link, window); } return state; @@ -434,8 +456,16 @@ static SessionState Apply(SessionState state, ViewerInput input, OwnerLink? link /// to them and letting the next refresh bring the result back, which keeps the round trip and /// the ten second mutex behind it off this thread. /// - static SessionState Dispatch(SessionState state, Command command, OwnerLink? link) + static SessionState Dispatch(SessionState state, Command command, OwnerLink? link, IViewerWindow window) { + // Before everything, including the link check. Copying reads what is on screen and writes + // it to this machine's clipboard, so it is never something to ask an owner for - and the + // owner's answer would be the text this process already has. + if (command.Kind is CommandKind.Copy or CommandKind.CopyLeft or CommandKind.CopyRight) + { + return Copy(state, command.Kind, window); + } + if (link is null) { return ViewerSession.Apply(state, command, ViewerActions.Real); @@ -479,6 +509,59 @@ static SessionState Dispatch(SessionState state, Command command, OwnerLink? lin }; } + /// + /// Pane text to the clipboard, and a status line saying what went. An empty side or an empty + /// selection says so rather than silently putting nothing on the clipboard, since the two are + /// indistinguishable afterwards. + /// + static SessionState Copy(SessionState state, CommandKind kind, IViewerWindow window) + { + if (state.Current is not { } current) + { + return state with { Menu = null }; + } + + string text; + string what; + if (kind == CommandKind.Copy) + { + if (state.LiveSelection is not { IsEmpty: false } selection) + { + return state with + { + Message = "Nothing is selected. Drag across a pane, or press ctrl+a.", + Menu = null + }; + } + + text = SelectionText.Of(selection, current); + what = "the selection"; + } + else + { + var side = kind == CommandKind.CopyLeft ? PaneSide.Left : PaneSide.Right; + text = SelectionText.All(current, side); + what = SelectionText.Header(current, side); + } + + if (text.Length == 0) + { + return state with + { + Message = $"Nothing to copy from {what}.", + Menu = null + }; + } + + window.SetClipboard(text); + var lines = text.Count(_ => _ == '\n') + 1; + return state with + { + Message = $"Copied {lines} line{(lines == 1 ? "" : "s")} from {what}.", + Menu = null + }; + } + /// /// A group command against someone else's queue: one accept or discard per member, by key, /// with conflicted entries skipped the way every bulk accept skips them. The results come diff --git a/src/DiffEngineViewer/ViewerSession.cs b/src/DiffEngineViewer/ViewerSession.cs index ae082001..4d1c933f 100644 --- a/src/DiffEngineViewer/ViewerSession.cs +++ b/src/DiffEngineViewer/ViewerSession.cs @@ -289,7 +289,100 @@ public static SessionState OpenMenu(SessionState state, int visibleRow) var selected = Select(state, row.EntryIndex); return selected with { - Menu = new(fullRow, ContextMenu.ForEntry(selected.Queue[row.EntryIndex]), [row.EntryIndex]) + Menu = new( + fullRow, + ContextMenu.ForEntry( + selected.Queue[row.EntryIndex], + selected.LiveSelection is { IsEmpty: false }), + [row.EntryIndex]) + }; + } + + /// + /// The reader dragging out a range of pane text. The two ends arrive already in rows of the + /// whole side rather than of the visible slice, because a head knows the scroll top it drew + /// with and a drag that continues across a wheel notch has to mean the same thing either side + /// of it. + /// + /// Reported for as long as the button is held, and simply not reported once it is let go: the + /// selection is already here, so there is nothing for a release to say. That is what makes a + /// whole press-drag-release landing inside one frame come out right. + /// + /// + public static SessionState Drag( + SessionState state, + PaneSide side, + int anchorRow, + int anchorColumn, + int focusRow, + int focusColumn) + { + if (state.Current is not { } current) + { + return state; + } + + var selection = SelectionText.Clamp( + new( + current.Key, + current.SelectedVariant, + side, + anchorRow, + anchorColumn, + focusRow, + focusColumn), + current); + + // The identical state when the pointer has not left the cell it was in, which is most + // frames of a drag. A fresh record every frame would repaint three heads for nothing. + if (state.Menu is null && + selection == state.Selection) + { + return state; + } + + // A drag is the user moving on, so it closes an open menu like every other input, and + // drops whatever the last command reported: the status line is about to describe this + // selection, and "Copied 3 lines" sitting over a different one is a lie. + return state with + { + Selection = selection, + Message = null, + Menu = null + }; + } + + /// + /// Everything on one side. The side of the current selection, so select-all after a click in + /// the expected pane takes that pane, and the received one before anything has been pointed + /// at. + /// + static SessionState SelectAll(SessionState state) + { + if (state.Current is not { } current) + { + return state; + } + + var side = state.LiveSelection?.Side ?? PaneSide.Left; + var rows = SelectionText.Rows(current, side); + if (rows.Count == 0) + { + return state with { Selection = null }; + } + + var last = rows.Count - 1; + return state with + { + Message = null, + Selection = new( + current.Key, + current.SelectedVariant, + side, + 0, + 0, + last, + RowText.Flatten(rows[last].Text).Length) }; } @@ -368,6 +461,8 @@ public static SessionState Apply(SessionState state, Command command, ViewerActi : DiscardInline(state); case CommandKind.DiscardAll: return inline ? DiscardAllInline(state, actions) : DiscardFile(state); + case CommandKind.SelectAll: + return SelectAll(state); case CommandKind.NextVariant: return NextVariant(state); case CommandKind.Quit: