Skip to content

Commit ac94299

Browse files
committed
fix(datagrid): overlay the inline cell editor exactly on the drawn cell
1 parent 9a5d3ba commit ac94299

7 files changed

Lines changed: 300 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2121

2222
### Fixed
2323

24+
- Inline cell editor opening taller than the row and shifting a single-line value instead of overlaying it.
2425
- Parse error on any MongoDB filter written in shell syntax, such as `db.orders.find({status: 1})`.
2526
- MongoDB `.sort()` and `.projection()` silently ignored when written with unquoted keys.
2627
- Compare & Sync unable to drop an overloaded PostgreSQL routine, or any trigger.

TablePro/Views/Results/CellOverlayBase.swift

Lines changed: 59 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -86,22 +86,56 @@ class CellOverlayBase: NSObject {
8686
onRemove?()
8787
}
8888

89+
static let maximumOverlayHeight: CGFloat = 120
90+
91+
/// A single-line value gets exactly the cell it is editing, which is what keeps the
92+
/// glyphs from moving when the overlay opens. Only a value that actually breaks into
93+
/// lines grows, and its height budget comes from the same geometry the text view is
94+
/// configured with: `textContainerInset` is symmetric, so the content pays the top
95+
/// inset twice.
8996
static func overlayFrame(for cellFrame: NSRect, value: String) -> NSRect {
90-
let lineHeight = ThemeEngine.shared.dataGridFonts.regular.boundingRectForFont.height + 4
91-
var newlineCount = 0
92-
for scalar in value.unicodeScalars where scalar == "\n" {
93-
newlineCount += 1
94-
}
95-
let lineCount = CGFloat(newlineCount + 1)
96-
let contentHeight = max(lineCount * lineHeight + 8, cellFrame.height)
97-
let height = min(max(contentHeight, cellFrame.height), 120)
97+
let breaks = lineBreakCount(in: value)
98+
guard breaks > 0 else { return cellFrame }
99+
100+
let font = ThemeEngine.shared.valueFont
101+
let inset = DataGridCellTextGeometry.textContainerTopInset(
102+
rowHeight: cellFrame.height, font: font
103+
)
104+
let lineCount = CGFloat(breaks + 1)
105+
let contentHeight = lineCount * DataGridCellTextGeometry.lineHeight(for: font) + 2 * inset
106+
let height = min(max(contentHeight, cellFrame.height), maximumOverlayHeight)
98107
return NSRect(x: cellFrame.origin.x, y: cellFrame.origin.y, width: cellFrame.width, height: height)
99108
}
100109

110+
/// Counts the breaks TextKit lays out, not just LF: a lone CR, NEL, or a Unicode line or
111+
/// paragraph separator each start a new line fragment, and CRLF is one break. Counting
112+
/// only "\n" classified a "line1\rline2" value as single-line, which sized the overlay
113+
/// to one row and hid the second line behind it.
114+
static func lineBreakCount(in value: String) -> Int {
115+
var count = 0
116+
var previousWasCarriageReturn = false
117+
for scalar in value.unicodeScalars {
118+
switch scalar.value {
119+
case 0x0A:
120+
if !previousWasCarriageReturn { count += 1 }
121+
previousWasCarriageReturn = false
122+
case 0x0D:
123+
count += 1
124+
previousWasCarriageReturn = true
125+
case 0x85, 0x2028, 0x2029:
126+
count += 1
127+
previousWasCarriageReturn = false
128+
default:
129+
previousWasCarriageReturn = false
130+
}
131+
}
132+
return count
133+
}
134+
101135
static func makeContainer(frame: NSRect) -> CellOverlayContainerView {
102136
let container = CellOverlayContainerView(frame: frame)
103137
container.wantsLayer = true
104-
container.layer?.borderWidth = 2
138+
container.layer?.borderWidth = 1
105139
container.layer?.cornerRadius = 2
106140
container.layer?.masksToBounds = true
107141
container.applyLayerColors()
@@ -130,18 +164,32 @@ class CellOverlayBase: NSObject {
130164
textView.textContainer?.containerSize = unbounded
131165
}
132166

133-
static func makeScrollView(in container: NSView) -> NSScrollView {
167+
/// A row-height overlay holding a font taller than the row would otherwise show a
168+
/// vertical scroller and scroll its own descenders; a single-line value has nothing to
169+
/// scroll to, so the vertical axis is shut off entirely.
170+
static func makeScrollView(in container: NSView, scrollsVertically: Bool) -> NSScrollView {
134171
let scrollView = NSScrollView(frame: container.bounds)
135172
scrollView.autoresizingMask = [.width, .height]
136-
scrollView.hasVerticalScroller = true
173+
scrollView.hasVerticalScroller = scrollsVertically
137174
scrollView.hasHorizontalScroller = false
138175
scrollView.autohidesScrollers = true
139176
scrollView.borderType = .noBorder
140177
scrollView.drawsBackground = true
141178
scrollView.backgroundColor = .textBackgroundColor
179+
if !scrollsVertically {
180+
scrollView.verticalScrollElasticity = .none
181+
}
142182
return scrollView
143183
}
144184

185+
static func configureCellTextGeometry(of textView: NSTextView, rowHeight: CGFloat, font: NSFont) {
186+
textView.textContainer?.lineFragmentPadding = DataGridMetrics.cellHorizontalInset
187+
textView.textContainerInset = NSSize(
188+
width: 0,
189+
height: DataGridCellTextGeometry.textContainerTopInset(rowHeight: rowHeight, font: font)
190+
)
191+
}
192+
145193
private func installDismissObservers() {
146194
guard let hostTableView else { return }
147195

TablePro/Views/Results/CellOverlayEditor.swift

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import AppKit
88
@MainActor
99
final class CellOverlayEditor: CellOverlayBase, NSTextViewDelegate {
1010
private var editorTextView: OverlayTextView?
11+
private var editorScrollView: NSScrollView?
12+
private var editedCellFrame: NSRect = .zero
1113
private var initialValue: String = ""
1214

1315
var onCommit: ((_ row: Int, _ columnIndex: Int, _ newValue: String) -> Void)?
@@ -27,19 +29,23 @@ final class CellOverlayEditor: CellOverlayBase, NSTextViewDelegate {
2729
guard let window = tableView.window else { return }
2830

2931
let frame = Self.overlayFrame(for: cellFrame, value: value)
32+
let font = ThemeEngine.shared.valueFont
3033
let containerView = Self.makeContainer(frame: frame)
31-
let scrollView = Self.makeScrollView(in: containerView)
34+
let scrollView = Self.makeScrollView(
35+
in: containerView, scrollsVertically: frame.height > cellFrame.height
36+
)
3237

3338
let textView = OverlayTextView(frame: scrollView.bounds)
3439
textView.overlayEditor = self
3540
textView.isEditable = true
3641
textView.isRichText = false
3742
textView.allowsUndo = true
38-
textView.font = ThemeEngine.shared.valueFont
43+
textView.font = font
3944
textView.textColor = .labelColor
4045
textView.backgroundColor = .textBackgroundColor
4146
textView.focusRingType = .none
4247
Self.applyCellTextLayout(to: textView)
48+
Self.configureCellTextGeometry(of: textView, rowHeight: cellFrame.height, font: font)
4349
textView.delegate = self
4450
textView.string = value
4551
textView.selectAll(nil)
@@ -49,6 +55,8 @@ final class CellOverlayEditor: CellOverlayBase, NSTextViewDelegate {
4955

5056
initialValue = value
5157
editorTextView = textView
58+
editorScrollView = scrollView
59+
editedCellFrame = cellFrame
5260

5361
install(in: tableView, row: row, column: column, columnIndex: columnIndex, container: containerView)
5462
window.makeFirstResponder(textView)
@@ -66,6 +74,8 @@ final class CellOverlayEditor: CellOverlayBase, NSTextViewDelegate {
6674
let dismissColumnIndex = columnIndex
6775

6876
editorTextView = nil
77+
editorScrollView = nil
78+
editedCellFrame = .zero
6979
initialValue = ""
7080
removeOverlay()
7181

@@ -74,6 +84,20 @@ final class CellOverlayEditor: CellOverlayBase, NSTextViewDelegate {
7484
}
7585
}
7686

87+
/// Option+Return and pasted text can turn a single-line edit into a multiline one after
88+
/// the overlay opened, and the row-height overlay would clip the new lines with no
89+
/// affordance that they exist. The frame follows the text, exactly as it would have been
90+
/// framed had the value arrived that way.
91+
func textDidChange(_ notification: Notification) {
92+
guard let textView = editorTextView, let container = containerView else { return }
93+
let frame = Self.overlayFrame(for: editedCellFrame, value: textView.string)
94+
guard frame != container.frame else { return }
95+
container.frame = frame
96+
let grew = frame.height > editedCellFrame.height
97+
editorScrollView?.hasVerticalScroller = grew
98+
editorScrollView?.verticalScrollElasticity = grew ? .automatic : .none
99+
}
100+
77101
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
78102
if commandSelector == #selector(NSResponder.insertNewline(_:)) {
79103
if NSApp.currentEvent?.modifierFlags.contains(.option) == true {

TablePro/Views/Results/CellOverlayViewer.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,21 @@ final class CellOverlayViewer: CellOverlayBase, NSTextViewDelegate {
2121
guard let window = tableView.window else { return }
2222

2323
let frame = Self.overlayFrame(for: cellFrame, value: value)
24+
let font = ThemeEngine.shared.valueFont
2425
let containerView = Self.makeContainer(frame: frame)
25-
let scrollView = Self.makeScrollView(in: containerView)
26+
let scrollView = Self.makeScrollView(
27+
in: containerView, scrollsVertically: frame.height > cellFrame.height
28+
)
2629

2730
let textView = NSTextView(frame: scrollView.bounds)
2831
textView.isEditable = false
2932
textView.isSelectable = true
3033
textView.isRichText = false
31-
textView.font = ThemeEngine.shared.valueFont
34+
textView.font = font
3235
textView.textColor = .labelColor
3336
textView.backgroundColor = .textBackgroundColor
3437
Self.applyCellTextLayout(to: textView)
38+
Self.configureCellTextGeometry(of: textView, rowHeight: cellFrame.height, font: font)
3539
textView.delegate = self
3640
textView.string = value
3741
textView.selectAll(nil)

TablePro/Views/Results/Cells/DataGridCellRenderer.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,9 @@ final class DataGridCellRenderer {
7474
? (CTLineCreateTruncatedLine(fullLine, Double(availableWidth), .end, ellipsis) ?? ellipsis)
7575
: fullLine
7676

77-
let font = appearance.font
78-
let baselineOffset = (rect.height - font.ascender + font.descender - font.leading) / 2 + font.ascender
77+
let baselineOffset = DataGridCellTextGeometry.baselineY(
78+
rowHeight: rect.height, font: appearance.font
79+
)
7980

8081
context.saveGState()
8182
context.textMatrix = CGAffineTransform(scaleX: 1, y: -1)
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
//
2+
// DataGridCellTextGeometry.swift
3+
// TablePro
4+
//
5+
// The one owner of where a cell's glyphs sit, shared by the CoreText draw path and the
6+
// overlay editor so the two cannot disagree. Before it existed each side had its own
7+
// numbers and an inline edit visibly shifted the value it was editing.
8+
//
9+
10+
import AppKit
11+
12+
@MainActor
13+
enum DataGridCellTextGeometry {
14+
/// Baseline questions go through a detached layout manager, never a text view's
15+
/// `layoutManager` property: one read of that property downgrades a TextKit 2 view to
16+
/// TextKit 1, which reverts the overlay's no-wrap layout fix (#2381). Measured: the
17+
/// detached answer equals the TextKit 2 first-fragment glyph origin.
18+
private static let baselineMeasurer = NSLayoutManager()
19+
20+
/// The centered baseline the renderer draws at, floored to a whole point because that
21+
/// is where TextKit puts it: measured at 1x and 2x backing, TextKit floors a rendered
22+
/// baseline to integral points while `CTLineDraw` honors fractions. Flooring the shared
23+
/// target is what lets the editor land on the drawn glyphs exactly at every scale.
24+
static func baselineY(rowHeight: CGFloat, font: NSFont) -> CGFloat {
25+
((rowHeight - font.ascender + font.descender - font.leading) / 2 + font.ascender)
26+
.rounded(.down)
27+
}
28+
29+
/// The symmetric `textContainerInset.height` that puts an overlay text view's first
30+
/// baseline on `baselineY`. Negative when the font outgrows the row; AppKit accepts a
31+
/// negative inset and parity holds.
32+
static func textContainerTopInset(rowHeight: CGFloat, font: NSFont) -> CGFloat {
33+
baselineY(rowHeight: rowHeight, font: font) - baselineMeasurer.defaultBaselineOffset(for: font)
34+
}
35+
36+
static func lineHeight(for font: NSFont) -> CGFloat {
37+
baselineMeasurer.defaultLineHeight(for: font)
38+
}
39+
}

0 commit comments

Comments
 (0)