diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f4830bc9..9295bd356 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - New tab via Cmd+T no longer flashes focus back to the previous tab in the same window group - Cmd+X with no selection cuts the current line, matching VS Code, Sublime, and Xcode (#1075) +- Cmd+A on a query ending with a newline now highlights every line, not just the first (#1075) ### Added diff --git a/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift b/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift index 5f0947c1c..773c9e32e 100644 --- a/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift +++ b/LocalPackages/CodeEditTextView/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift @@ -75,12 +75,26 @@ extension TextSelectionManager { let endOfLine = fragmentRange.max <= range.max || range.contains(fragmentRange.max) let endOfDocument = intersectionRange.max == layoutManager.lineStorage.length let emptyLine = linePosition.range.isEmpty + // If the line ends with a line-break character, the selection logically continues onto a + // (possibly empty) trailing line and the highlight should extend to the right edge — even + // when this fragment is at the end of the document. Without this check, the very last + // fragment of a buffer that ends in `\n` collapses to zero width because + // `rectForOffset(lineStorage.length)` resolves to the trailing-empty-line position at + // the leading edge. + let lineEndsWithNewline: Bool = { + guard !linePosition.range.isEmpty, + let textStorage = layoutManager.textStorage, + let lineString = textStorage.substring(from: linePosition.range) else { + return false + } + return LineEnding(line: lineString) != nil + }() // If the selection is at the end of the line, or contains the end of the fragment, and is not the end // of the document, we select the entire line to the right of the selection point. // true, !true = false, false // true, !true = false, true - if endOfLine && !(endOfDocument && !emptyLine) { + if endOfLine && !(endOfDocument && !emptyLine && !lineEndsWithNewline) { maxRect = CGRect( x: rect.maxX, y: fragmentPosition.yPos + linePosition.yPos, diff --git a/LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/CmdShiftLeftAtEndOfDocumentTests.swift b/LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/CmdShiftLeftAtEndOfDocumentTests.swift new file mode 100644 index 000000000..a1df2a60d --- /dev/null +++ b/LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/CmdShiftLeftAtEndOfDocumentTests.swift @@ -0,0 +1,191 @@ +import AppKit +@testable import CodeEditTextView +import Testing + +/// Regression tests for word-granularity selection extension when the cursor +/// sits at the very end of the document. Standard macOS NSTextView behavior: +/// - Cmd+Shift+Left at the end of "select * from products" extends the +/// selection backward to cover the last word "products". +/// - Cmd+Left at the end jumps the caret to the start of the last word. +/// +/// Issue #1075: cursor at end of buffer with no preceding selection cannot +/// extend selection backward by word. +@Suite +@MainActor +struct CmdShiftLeftAtEndOfDocumentTests { + private func makeLaidOutTextView(_ text: String) -> TextView { + let textView = TextView(string: text) + textView.frame = NSRect(x: 0, y: 0, width: 1_000, height: 1_000) + textView.updateFrameIfNeeded() + textView.layoutManager.layoutLines(in: NSRect(x: 0, y: 0, width: 1_000, height: 1_000)) + return textView + } + + // MARK: - rangeOfSelection (pure range computation) + + @Test("Cmd+Left at end of single-line query returns range covering last word") + func cmdLeftRangeAtEndOfSingleLine() { + let textView = makeLaidOutTextView("select * from products") + let length = (textView.string as NSString).length + + let range = textView.selectionManager.rangeOfSelection( + from: length, + direction: .backward, + destination: .word + ) + + #expect(range == NSRange(location: 14, length: 8)) + } + + @Test("Cmd+Left at end of multi-line query returns range covering last word") + func cmdLeftRangeAtEndOfMultiLine() { + let textView = makeLaidOutTextView("select *\nfrom products") + let length = (textView.string as NSString).length + + let range = textView.selectionManager.rangeOfSelection( + from: length, + direction: .backward, + destination: .word + ) + + #expect(range == NSRange(location: 14, length: 8)) + } + + // MARK: - Full moveWordLeftAndModifySelection flow (covers pivot logic) + + @Test("Cmd+Shift+Left at end of single-line query selects the last word") + func cmdShiftLeftSelectsLastWordAtEnd() { + let textView = makeLaidOutTextView("select * from products") + let length = (textView.string as NSString).length + + textView.selectionManager.setSelectedRange(NSRange(location: length, length: 0)) + textView.moveWordLeftAndModifySelection(nil) + + guard let selection = textView.selectionManager.textSelections.first else { + Issue.record("Expected one selection") + return + } + #expect(selection.range == NSRange(location: 14, length: 8)) + } + + @Test("Cmd+Shift+Left twice at end extends across two words") + func cmdShiftLeftTwiceExtendsAcrossTwoWords() { + let textView = makeLaidOutTextView("select * from products") + let length = (textView.string as NSString).length + + textView.selectionManager.setSelectedRange(NSRange(location: length, length: 0)) + textView.moveWordLeftAndModifySelection(nil) + textView.moveWordLeftAndModifySelection(nil) + + guard let selection = textView.selectionManager.textSelections.first else { + Issue.record("Expected one selection") + return + } + // Selection should now cover "from products" — from offset 9 to 22. + #expect(selection.range == NSRange(location: 9, length: 13)) + } + + @Test("Cmd+Left at end moves caret to start of last word") + func cmdLeftMovesCaretToStartOfLastWord() { + let textView = makeLaidOutTextView("select * from products") + let length = (textView.string as NSString).length + + textView.selectionManager.setSelectedRange(NSRange(location: length, length: 0)) + textView.moveWordLeft(nil) + + guard let selection = textView.selectionManager.textSelections.first else { + Issue.record("Expected one selection") + return + } + #expect(selection.range == NSRange(location: 14, length: 0)) + } + + // MARK: - Pivot reset across separate selection sessions + + /// Reproduces the user's likely workflow: extend selection forward, + /// click somewhere else (caret reset), then try to extend backward. + /// The stale pivot from the prior session must not leak into the new one. + @Test("After click-resetting caret to end, Cmd+Shift+Left selects last word") + func clickResetsPivotForBackwardExtension() { + let textView = makeLaidOutTextView("select * from products") + let length = (textView.string as NSString).length + + // Session 1: cursor at start, extend forward by word. + textView.selectionManager.setSelectedRange(NSRange(location: 0, length: 0)) + textView.moveWordRightAndModifySelection(nil) + + // Session 2: user clicks at end (caret reset). + textView.selectionManager.setSelectedRange(NSRange(location: length, length: 0)) + + // Cmd+Shift+Left should select the last word. + textView.moveWordLeftAndModifySelection(nil) + + guard let selection = textView.selectionManager.textSelections.first else { + Issue.record("Expected one selection") + return + } + #expect(selection.range == NSRange(location: 14, length: 8)) + } + + // MARK: - Cmd+Left / Cmd+Shift+Left at end (line-granularity, NOT word) + + /// Cmd+Left on macOS is `moveToBeginningOfLine:` — line-start, not word. + /// At the end of a single-line buffer, the caret should jump to offset 0. + @Test("Cmd+Left at end of single-line moves caret to line start (offset 0)") + func cmdLeftAtEndJumpsToLineStart() { + let textView = makeLaidOutTextView("select * from products") + let length = (textView.string as NSString).length + + textView.selectionManager.setSelectedRange(NSRange(location: length, length: 0)) + textView.moveToBeginningOfLine(nil) + + guard let selection = textView.selectionManager.textSelections.first else { + Issue.record("Expected one selection") + return + } + #expect(selection.range == NSRange(location: 0, length: 0)) + } + + /// Cmd+Shift+Left at the end of a single-line buffer should extend the + /// selection backward to offset 0 — selecting the entire line. + @Test("Cmd+Shift+Left at end of single-line selects entire line") + func cmdShiftLeftAtEndSelectsEntireLine() { + let textView = makeLaidOutTextView("select * from products") + let length = (textView.string as NSString).length + + textView.selectionManager.setSelectedRange(NSRange(location: length, length: 0)) + textView.moveToBeginningOfLineAndModifySelection(nil) + + guard let selection = textView.selectionManager.textSelections.first else { + Issue.record("Expected one selection") + return + } + #expect(selection.range == NSRange(location: 0, length: length)) + } + + @Test("Pressing End then Cmd+Shift+Left selects last word") + func endThenCmdShiftLeftSelectsLastWord() { + let textView = makeLaidOutTextView("select * from products") + let length = (textView.string as NSString).length + + // Start somewhere in the middle. + textView.selectionManager.setSelectedRange(NSRange(location: 5, length: 0)) + // Press End — moveToEndOfLine. + textView.moveToEndOfLine(nil) + + guard let afterEnd = textView.selectionManager.textSelections.first else { + Issue.record("Expected one selection after End") + return + } + #expect(afterEnd.range == NSRange(location: length, length: 0)) + + // Now Cmd+Shift+Left. + textView.moveWordLeftAndModifySelection(nil) + + guard let selection = textView.selectionManager.textSelections.first else { + Issue.record("Expected one selection") + return + } + #expect(selection.range == NSRange(location: 14, length: 8)) + } +} diff --git a/LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/SelectAllWithTrailingNewlineTests.swift b/LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/SelectAllWithTrailingNewlineTests.swift new file mode 100644 index 000000000..62491ad10 --- /dev/null +++ b/LocalPackages/CodeEditTextView/Tests/CodeEditTextViewTests/SelectAllWithTrailingNewlineTests.swift @@ -0,0 +1,243 @@ +import AppKit +@testable import CodeEditTextView +import Testing + +/// Regression tests for Cmd+A (`selectAll:`) when the buffer has a trailing +/// empty line (text ending in `\n`). The selection must cover the entire +/// `textStorage`, including the trailing newline — otherwise the visually +/// last line is dropped from copy/cut/replace operations. +@Suite +@MainActor +struct SelectAllWithTrailingNewlineTests { + private func makeLaidOutTextView(_ text: String) -> TextView { + let textView = TextView(string: text) + textView.frame = NSRect(x: 0, y: 0, width: 1_000, height: 1_000) + textView.font = .monospacedSystemFont(ofSize: 14, weight: .regular) + textView.updateFrameIfNeeded() + // updateFrameIfNeeded shrinks the frame width to the longest line; force + // back to the original width so getFillRects has horizontal room. + textView.frame.size.width = 1_000 + textView.layoutManager.invalidateLayoutForRange(textView.documentRange) + textView.layoutManager.layoutLines(in: NSRect(x: 0, y: 0, width: 1_000, height: 1_000)) + return textView + } + + // MARK: - documentRange + + @Test("documentRange equals textStorage.length for buffer without trailing newline") + func documentRangeNoTrailingNewline() { + let text = "line1\nline2\nline3\nline4\nline5" + let textView = makeLaidOutTextView(text) + #expect(textView.documentRange == NSRange(location: 0, length: 29)) + #expect(textView.documentRange.length == (text as NSString).length) + } + + @Test("documentRange equals textStorage.length for buffer with trailing newline") + func documentRangeWithTrailingNewline() { + let text = "line1\nline2\nline3\nline4\nline5\n" + let textView = makeLaidOutTextView(text) + #expect(textView.documentRange == NSRange(location: 0, length: 30)) + #expect(textView.documentRange.length == (text as NSString).length) + } + + // MARK: - selectAll → selectedRange + + @Test("Cmd+A on buffer without trailing newline selects every character") + func selectAllNoTrailingNewlineCoversAll() { + let textView = makeLaidOutTextView("line1\nline2\nline3\nline4\nline5") + textView.selectAll(nil) + #expect(textView.selectedRange() == NSRange(location: 0, length: 29)) + } + + @Test("Cmd+A on buffer with trailing newline selects every character including the newline") + func selectAllWithTrailingNewlineCoversAll() { + let textView = makeLaidOutTextView("line1\nline2\nline3\nline4\nline5\n") + textView.selectAll(nil) + #expect(textView.selectedRange() == NSRange(location: 0, length: 30)) + } + + @Test("Cmd+A on buffer with two trailing newlines (visible blank line) selects all of it") + func selectAllWithDoubleTrailingNewlineCoversAll() { + let text = "line1\nline2\n\n" + let textView = makeLaidOutTextView(text) + textView.selectAll(nil) + #expect(textView.selectedRange() == NSRange(location: 0, length: (text as NSString).length)) + } + + // MARK: - selectAll → copy → clipboard + + @Test("Cmd+A then native copy on trailing-newline buffer writes the full text to clipboard") + func selectAllThenCopyWritesFullText() { + let text = "line1\nline2\nline3\nline4\nline5\n" + let textView = makeLaidOutTextView(text) + + // Use a fresh pasteboard so other tests don't interfere. + let pasteboard = NSPasteboard.general + pasteboard.clearContents() + textView.selectAll(nil) + textView.copy(NSObject()) + + let copied = pasteboard.string(forType: .string) + #expect(copied == text) + } + + /// Mirrors TablePro's `EditorEventRouter.handleKeyDown` Cmd+C intercept: + /// after `selectAll`, take `textView.selectedRange()` + `textView.string` + /// and substring. The substring must equal the full buffer. + @Test("After selectAll, substring(textView.string, selectedRange) equals the full text") + func selectAllThenManualSubstringMatchesFullText() { + let text = "select * from products\nselect * from orders\nselect 1\n" + let textView = makeLaidOutTextView(text) + + textView.selectAll(nil) + let selection = textView.selectedRange() + let copied = (textView.string as NSString).substring(with: selection) + + #expect(selection == NSRange(location: 0, length: (text as NSString).length)) + #expect(copied == text) + } + + /// Buffer with multiple trailing newlines (visible empty lines) — checks + /// the substring path doesn't drop the trailing content. + @Test("After selectAll on trailing-empty-line buffer, substring includes every newline") + func selectAllSubstringIncludesTrailingEmptyLines() { + let text = "row1\nrow2\nrow3\nrow4\nrow5\n" + let textView = makeLaidOutTextView(text) + + textView.selectAll(nil) + let selection = textView.selectedRange() + let copied = (textView.string as NSString).substring(with: selection) + + #expect(copied == text) + #expect(copied.hasSuffix("\n")) + } + + // MARK: - Visual highlight (fillRects) + + /// User-reported #1075: after Cmd+A on a buffer ending with `\n`, the blue + /// highlight visually covers only the lines BEFORE the trailing empty one. + /// `getFillRects` is what produces the highlight rectangles — it should + /// emit one rect per visible-line fragment that the selection touches. + @Test("getFillRects covers every text line for selectAll on trailing-newline buffer") + func getFillRectsCoversAllLinesWithTrailingNewline() { + let textView = makeLaidOutTextView("row1\nrow2\nrow3\nrow4\nrow5\n") + textView.selectAll(nil) + + guard let selection = textView.selectionManager.textSelections.first else { + Issue.record("Expected one selection") + return + } + let rects = textView.selectionManager.getFillRects( + in: textView.frame, for: selection + ) + // Five text lines must each contribute at least one fill rect. + #expect(rects.count >= 5, "Expected ≥5 fill rects, got \(rects.count)") + } + + @Test("getFillRects covers every text line for selectAll on buffer without trailing newline") + func getFillRectsCoversAllLinesWithoutTrailingNewline() { + let textView = makeLaidOutTextView("row1\nrow2\nrow3\nrow4\nrow5") + textView.selectAll(nil) + + guard let selection = textView.selectionManager.textSelections.first else { + Issue.record("Expected one selection") + return + } + let rects = textView.selectionManager.getFillRects( + in: textView.frame, for: selection + ) + #expect(rects.count >= 5, "Expected ≥5 fill rects, got \(rects.count)") + } + + /// User-reported repro from issue #1075: SQL editor with two `select * from users;` + /// lines plus a trailing newline. After Cmd+A, only the FIRST line shows the + /// blue highlight; line 2's selection rect ends up zero-width because the + /// `else` branch of `getFillRects` resolves the line-end to a 0-width rect + /// at exactly `intersectionRange.max == lineStorage.length`. + /// + /// We assert against the rect's right edge instead of width — without a real + /// window the typesetter returns zero-width glyphs in tests, so widths are + /// always 0. The right-edge x position still differentiates the two branches: + /// the IF branch sets `maxX` to the right edge of the fill area, while the + /// ELSE branch leaves `maxX` near the line's leading edge (≈ `minX`). + /// Issue #1075 — exact user repro. The buffer has two text lines plus a + /// trailing newline. Before the fix, the LAST text line's fill rect + /// collapsed to zero width because `intersectionRange.max == + /// lineStorage.length` routed it through the else branch, which resolves + /// to the trailing-empty-line position at the leading edge. + /// + /// Both text lines end with `\n`, so both must extend to the right edge. + @Test("Cmd+A on `users;\\nusers;\\n` highlights both text lines to right edge (#1075)") + func selectAllExtendsBothLinesToRightEdge() { + let textView = makeLaidOutTextView("select * from users;\nselect * from users;\n") + textView.selectAll(nil) + + guard let selection = textView.selectionManager.textSelections.first else { + Issue.record("Expected one selection") + return + } + let rects = textView.selectionManager + .getFillRects(in: textView.frame, for: selection) + .sorted { $0.minY < $1.minY } + + guard rects.count == 2 else { + Issue.record("Expected exactly 2 fill rects, got \(rects.count)") + return + } + // Both lines must reach the available right edge (frame width here, since + // there's no narrower wrap width). + let frameWidth = textView.frame.width + for (index, rect) in rects.enumerated() { + #expect(rect.width >= frameWidth - 1.0, + "Line \(index) width = \(rect.width); expected ≈ \(frameWidth)") + } + } + + /// Counterpart: a buffer that does NOT end with `\n` should leave the + /// last line's highlight at the text's right edge, not the frame edge — + /// this is the original behavior we must preserve. + @Test("Cmd+A on non-newline-terminated buffer keeps last line highlight at text end") + func selectAllNonTerminatedKeepsLastLineAtTextEnd() { + let textView = makeLaidOutTextView("select * from users;\nselect * from users;") + textView.selectAll(nil) + + guard let selection = textView.selectionManager.textSelections.first else { + Issue.record("Expected one selection") + return + } + let rects = textView.selectionManager + .getFillRects(in: textView.frame, for: selection) + .sorted { $0.minY < $1.minY } + + guard rects.count == 2 else { + Issue.record("Expected 2 fill rects, got \(rects.count)") + return + } + // Line 1 ends with `\n` — extends to frame edge. + let frameWidth = textView.frame.width + #expect(rects[0].width >= frameWidth - 1.0, + "Line 0 width = \(rects[0].width); expected ≈ \(frameWidth)") + // Line 2 has no trailing `\n` — must NOT extend to the frame edge. + #expect(rects[1].width < frameWidth - 1.0, + "Line 1 width = \(rects[1].width); should not reach frame edge \(frameWidth)") + } + + @Test("Sum of fill-rect heights covers all five visible text lines") + func fillRectsHeightSpansAllLines() { + let textView = makeLaidOutTextView("row1\nrow2\nrow3\nrow4\nrow5\n") + textView.selectAll(nil) + + guard let selection = textView.selectionManager.textSelections.first, + let firstLine = textView.layoutManager.textLineForOffset(0) else { + Issue.record("Expected selection and a first line") + return + } + let rects = textView.selectionManager.getFillRects( + in: textView.frame, for: selection + ) + let totalHeight = rects.map(\.height).reduce(0, +) + // Five lines must cover ~5x a single line height. Use 4.5x as a safe lower bound. + #expect(totalHeight >= firstLine.height * 4.5, + "Total fill-rect height \(totalHeight) is less than 4.5x line height \(firstLine.height)") + } +} diff --git a/TablePro/Views/Editor/EditorEventRouter.swift b/TablePro/Views/Editor/EditorEventRouter.swift index da81d58f0..554e09e63 100644 --- a/TablePro/Views/Editor/EditorEventRouter.swift +++ b/TablePro/Views/Editor/EditorEventRouter.swift @@ -166,19 +166,24 @@ internal final class EditorEventRouter { return event } - let range = textView.selectedRange() - guard range.length > 0 else { return event } - let text = (textView.string as NSString).substring(with: range) + let selection = textView.selectedRange() switch event.keyCode { case 8: // Cmd+C + guard selection.length > 0 else { return event } + let text = (textView.string as NSString).substring(with: selection) NSPasteboard.general.clearContents() NSPasteboard.general.setString(text, forType: .string) return nil case 7: // Cmd+X + guard let result = LineCutCalculator.calculate( + text: textView.string, selection: selection + ) else { + return event + } NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) - textView.replaceCharacters(in: range, with: "") + NSPasteboard.general.setString(result.clipboardText, forType: .string) + textView.replaceCharacters(in: result.rangeToDelete, with: "") return nil default: break diff --git a/TablePro/Views/Editor/LineCutCalculator.swift b/TablePro/Views/Editor/LineCutCalculator.swift new file mode 100644 index 000000000..546dce8ee --- /dev/null +++ b/TablePro/Views/Editor/LineCutCalculator.swift @@ -0,0 +1,42 @@ +// +// LineCutCalculator.swift +// TablePro +// + +import Foundation + +/// Pure logic for resolving a Cmd+X cut operation on the SQL editor's text +/// view. When a selection exists the selection is the cut target; with no +/// selection the entire current line (including its trailing newline, if any) +/// is the cut target — matching the convention used by VS Code, Sublime, +/// JetBrains IDEs, and Xcode's source editor. +enum LineCutCalculator { + struct Result: Equatable { + let rangeToDelete: NSRange + let clipboardText: String + } + + static func calculate(text: String, selection: NSRange) -> Result? { + let nsText = text as NSString + guard nsText.length > 0 else { return nil } + guard selection.location >= 0, + selection.location <= nsText.length, + selection.location + selection.length <= nsText.length else { + return nil + } + + if selection.length > 0 { + return Result( + rangeToDelete: selection, + clipboardText: nsText.substring(with: selection) + ) + } + + let lineRange = nsText.lineRange(for: NSRange(location: selection.location, length: 0)) + guard lineRange.length > 0 else { return nil } + return Result( + rangeToDelete: lineRange, + clipboardText: nsText.substring(with: lineRange) + ) + } +} diff --git a/TableProTests/Views/Editor/LineCutCalculatorTests.swift b/TableProTests/Views/Editor/LineCutCalculatorTests.swift new file mode 100644 index 000000000..7e8b4a378 --- /dev/null +++ b/TableProTests/Views/Editor/LineCutCalculatorTests.swift @@ -0,0 +1,164 @@ +// +// LineCutCalculatorTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Line Cut Calculator") +struct LineCutCalculatorTests { + // MARK: - With Selection (existing Cmd+X behavior must not regress) + + @Test("Selection cuts only the selected text") + func selectionCutsSelectedText() { + let result = LineCutCalculator.calculate( + text: "hello world", + selection: NSRange(location: 6, length: 5) + ) + #expect(result == LineCutCalculator.Result( + rangeToDelete: NSRange(location: 6, length: 5), + clipboardText: "world" + )) + } + + @Test("Multi-line selection cuts only the selected substring") + func multiLineSelectionCutsSubstring() { + let result = LineCutCalculator.calculate( + text: "line1\nline2\nline3", + selection: NSRange(location: 3, length: 6) + ) + #expect(result == LineCutCalculator.Result( + rangeToDelete: NSRange(location: 3, length: 6), + clipboardText: "e1\nlin" + )) + } + + // MARK: - No Selection: cut current line (issue #1075) + + @Test("Single line without terminator cuts the entire content") + func singleLineNoTerminatorCutsAll() { + let result = LineCutCalculator.calculate( + text: "select * from users", + selection: NSRange(location: 5, length: 0) + ) + #expect(result == LineCutCalculator.Result( + rangeToDelete: NSRange(location: 0, length: 19), + clipboardText: "select * from users" + )) + } + + @Test("First line of multi-line cuts line plus trailing newline") + func firstLineCutsWithNewline() { + let result = LineCutCalculator.calculate( + text: "line1\nline2\nline3", + selection: NSRange(location: 2, length: 0) + ) + #expect(result == LineCutCalculator.Result( + rangeToDelete: NSRange(location: 0, length: 6), + clipboardText: "line1\n" + )) + } + + @Test("Middle line cuts line plus trailing newline") + func middleLineCutsWithNewline() { + let result = LineCutCalculator.calculate( + text: "line1\nline2\nline3", + selection: NSRange(location: 8, length: 0) + ) + #expect(result == LineCutCalculator.Result( + rangeToDelete: NSRange(location: 6, length: 6), + clipboardText: "line2\n" + )) + } + + @Test("Last line without trailing newline cuts the line text only") + func lastLineNoTerminatorCutsLineOnly() { + let result = LineCutCalculator.calculate( + text: "line1\nline2\nline3", + selection: NSRange(location: 14, length: 0) + ) + #expect(result == LineCutCalculator.Result( + rangeToDelete: NSRange(location: 12, length: 5), + clipboardText: "line3" + )) + } + + @Test("Last line with trailing newline cuts line plus newline") + func lastLineWithTerminatorCutsWithNewline() { + let result = LineCutCalculator.calculate( + text: "line1\nline2\n", + selection: NSRange(location: 8, length: 0) + ) + #expect(result == LineCutCalculator.Result( + rangeToDelete: NSRange(location: 6, length: 6), + clipboardText: "line2\n" + )) + } + + @Test("Cursor at start of line cuts that line") + func cursorAtStartOfLineCutsLine() { + let result = LineCutCalculator.calculate( + text: "line1\nline2\nline3", + selection: NSRange(location: 6, length: 0) + ) + #expect(result == LineCutCalculator.Result( + rangeToDelete: NSRange(location: 6, length: 6), + clipboardText: "line2\n" + )) + } + + @Test("Cursor between line text and trailing newline cuts that line") + func cursorBeforeNewlineCutsLine() { + let result = LineCutCalculator.calculate( + text: "line1\nline2\nline3", + selection: NSRange(location: 5, length: 0) + ) + #expect(result == LineCutCalculator.Result( + rangeToDelete: NSRange(location: 0, length: 6), + clipboardText: "line1\n" + )) + } + + @Test("Cursor on empty line cuts just the newline") + func cursorOnEmptyLineCutsNewline() { + let result = LineCutCalculator.calculate( + text: "line1\n\nline3", + selection: NSRange(location: 6, length: 0) + ) + #expect(result == LineCutCalculator.Result( + rangeToDelete: NSRange(location: 6, length: 1), + clipboardText: "\n" + )) + } + + // MARK: - No-op cases + + @Test("Empty text returns nil") + func emptyTextReturnsNil() { + let result = LineCutCalculator.calculate( + text: "", + selection: NSRange(location: 0, length: 0) + ) + #expect(result == nil) + } + + @Test("Cursor past end of text returns nil") + func cursorOutOfBoundsReturnsNil() { + let result = LineCutCalculator.calculate( + text: "abc", + selection: NSRange(location: 100, length: 0) + ) + #expect(result == nil) + } + + @Test("Cursor at end of buffer with trailing newline returns nil (no line below)") + func cursorAtTrailingEmptyLineReturnsNil() { + let result = LineCutCalculator.calculate( + text: "line1\n", + selection: NSRange(location: 6, length: 0) + ) + #expect(result == nil) + } +}