Skip to content

Commit ff967c8

Browse files
authored
feat(datagrid): read-only cell viewer with unified interaction dispatcher (#1341)
* feat(datagrid): read-only cell viewer with unified interaction dispatcher (#1336) * refactor(datagrid): route chevron action through cell interaction resolver (#1336) * fix(coordinator): restore CodeEditTextView import dropped in #1340 * fix(datagrid): keep double-click in edit mode, pickers only via chevron (#1336) * fix(datagrid): edit FK cell inline on double-click, remove FK picker popover (#1336) * fix(datagrid): allow inline editing of foreign key cells (#1336) * refactor(datagrid): trim CellContext to decision-relevant fields (#1336)
1 parent 29dd483 commit ff967c8

16 files changed

Lines changed: 746 additions & 540 deletions

CHANGELOG.md

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

1212
- Right-click a column header to copy all its values from the loaded rows (#1325)
1313
- Copy as submenu on the row context menu now offers CSV, CSV with Headers, Markdown table, and IN Clause for SQL `WHERE id IN (...)` lookups (#1325)
14+
- Double-click or press Return on a read-only query result cell to open a selectable text viewer in the cell. JSON columns open the JSON viewer in a popover, BLOB columns open the hex viewer. The value is selectable and copyable (#1336)
1415

1516
### Changed
1617

TablePro/Resources/Localizable.xcstrings

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49481,6 +49481,7 @@
4948149481
}
4948249482
},
4948349483
"Truncated — read only" : {
49484+
"extractionState" : "stale",
4948449485
"localizations" : {
4948549486
"tr" : {
4948649487
"stringUnit" : {
@@ -49501,6 +49502,9 @@
4950149502
}
4950249503
}
4950349504
}
49505+
},
49506+
"Truncated, read only" : {
49507+
4950449508
},
4950549509
"Trust" : {
4950649510
"localizations" : {
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
//
2+
// CellInteractionResolver.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
8+
internal struct CellContext: Equatable {
9+
let columnType: ColumnType?
10+
let value: String?
11+
let isTableEditable: Bool
12+
let isRowDeleted: Bool
13+
let isImmutableColumn: Bool
14+
}
15+
16+
internal enum CellInteractionMode: Equatable {
17+
case viewInline(value: String)
18+
case viewJson
19+
case viewBlob
20+
21+
case editInline(value: String)
22+
case editOverlay(value: String)
23+
case editJson
24+
case editBlob
25+
26+
case blocked
27+
}
28+
29+
internal struct CellInteractionResolver {
30+
func resolve(_ context: CellContext) -> CellInteractionMode {
31+
guard !context.isRowDeleted else { return .blocked }
32+
33+
let isReadOnly = !context.isTableEditable || context.isImmutableColumn
34+
35+
if isReadOnly {
36+
if let columnType = context.columnType {
37+
if columnType.isBlobType { return .viewBlob }
38+
if columnType.isJsonType { return .viewJson }
39+
}
40+
return .viewInline(value: context.value ?? "NULL")
41+
}
42+
43+
if let columnType = context.columnType {
44+
if columnType.isBlobType { return .editBlob }
45+
if columnType.isJsonType { return .editJson }
46+
}
47+
48+
let value = context.value ?? ""
49+
if value.containsLineBreak { return .editOverlay(value: value) }
50+
if value.looksLikeJson { return .editJson }
51+
return .editInline(value: value)
52+
}
53+
}
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
//
2+
// CellOverlayBase.swift
3+
// TablePro
4+
//
5+
6+
import AppKit
7+
8+
enum CellOverlayDismissReason {
9+
case userAction
10+
case scroll
11+
case columnResize
12+
case appResign
13+
case windowResignKey
14+
case outsideClick
15+
}
16+
17+
@MainActor
18+
class CellOverlayBase: NSObject {
19+
private var container: CellOverlayContainerView?
20+
private weak var hostTableView: NSTableView?
21+
private var scrollObserver: NSObjectProtocol?
22+
private var columnResizeObserver: NSObjectProtocol?
23+
private var appResignObserver: NSObjectProtocol?
24+
private var windowResignKeyObserver: NSObjectProtocol?
25+
private var outsideClickMonitor: Any?
26+
27+
private(set) var row: Int = -1
28+
private(set) var column: Int = -1
29+
private(set) var columnIndex: Int = -1
30+
31+
var isActive: Bool { container != nil }
32+
var containerView: NSView? { container }
33+
var tableView: NSTableView? { hostTableView }
34+
35+
func raiseToFront() {
36+
guard let container, let hostTableView, container.superview === hostTableView else { return }
37+
guard hostTableView.subviews.last !== container else { return }
38+
hostTableView.addSubview(container)
39+
}
40+
41+
func install(
42+
in tableView: NSTableView,
43+
row: Int,
44+
column: Int,
45+
columnIndex: Int,
46+
container: CellOverlayContainerView
47+
) {
48+
self.hostTableView = tableView
49+
self.row = row
50+
self.column = column
51+
self.columnIndex = columnIndex
52+
tableView.addSubview(container)
53+
self.container = container
54+
installDismissObservers()
55+
}
56+
57+
func handleDismiss(reason: CellOverlayDismissReason) {
58+
removeOverlay()
59+
}
60+
61+
func removeOverlay() {
62+
guard let activeContainer = container else { return }
63+
removeDismissObservers()
64+
activeContainer.removeFromSuperview()
65+
container = nil
66+
if let hostTableView {
67+
hostTableView.window?.makeFirstResponder(hostTableView)
68+
}
69+
}
70+
71+
static func overlayFrame(for cellFrame: NSRect, value: String) -> NSRect {
72+
let lineHeight = ThemeEngine.shared.dataGridFonts.regular.boundingRectForFont.height + 4
73+
var newlineCount = 0
74+
for scalar in value.unicodeScalars where scalar == "\n" {
75+
newlineCount += 1
76+
}
77+
let lineCount = CGFloat(newlineCount + 1)
78+
let contentHeight = max(lineCount * lineHeight + 8, cellFrame.height)
79+
let height = min(max(contentHeight, cellFrame.height), 120)
80+
return NSRect(x: cellFrame.origin.x, y: cellFrame.origin.y, width: cellFrame.width, height: height)
81+
}
82+
83+
static func makeContainer(frame: NSRect) -> CellOverlayContainerView {
84+
let container = CellOverlayContainerView(frame: frame)
85+
container.wantsLayer = true
86+
container.layer?.borderWidth = 2
87+
container.layer?.borderColor = NSColor.keyboardFocusIndicatorColor.cgColor
88+
container.layer?.cornerRadius = 2
89+
container.layer?.masksToBounds = true
90+
container.layer?.backgroundColor = NSColor.textBackgroundColor.cgColor
91+
return container
92+
}
93+
94+
static func makeScrollView(in container: NSView) -> NSScrollView {
95+
let scrollView = NSScrollView(frame: container.bounds)
96+
scrollView.autoresizingMask = [.width, .height]
97+
scrollView.hasVerticalScroller = true
98+
scrollView.hasHorizontalScroller = false
99+
scrollView.autohidesScrollers = true
100+
scrollView.borderType = .noBorder
101+
scrollView.drawsBackground = true
102+
scrollView.backgroundColor = .textBackgroundColor
103+
return scrollView
104+
}
105+
106+
private func installDismissObservers() {
107+
guard let hostTableView else { return }
108+
109+
if let clipView = hostTableView.enclosingScrollView?.contentView {
110+
scrollObserver = NotificationCenter.default.addObserver(
111+
forName: NSView.boundsDidChangeNotification,
112+
object: clipView,
113+
queue: .main
114+
) { [weak self] _ in
115+
MainActor.assumeIsolated {
116+
self?.handleDismiss(reason: .scroll)
117+
}
118+
}
119+
}
120+
121+
columnResizeObserver = NotificationCenter.default.addObserver(
122+
forName: NSTableView.columnDidResizeNotification,
123+
object: hostTableView,
124+
queue: .main
125+
) { [weak self] _ in
126+
MainActor.assumeIsolated {
127+
self?.handleDismiss(reason: .columnResize)
128+
}
129+
}
130+
131+
appResignObserver = NotificationCenter.default.addObserver(
132+
forName: NSApplication.didResignActiveNotification,
133+
object: nil,
134+
queue: .main
135+
) { [weak self] _ in
136+
MainActor.assumeIsolated {
137+
self?.handleDismiss(reason: .appResign)
138+
}
139+
}
140+
141+
if let overlayWindow = hostTableView.window {
142+
windowResignKeyObserver = NotificationCenter.default.addObserver(
143+
forName: NSWindow.didResignKeyNotification,
144+
object: overlayWindow,
145+
queue: .main
146+
) { [weak self] _ in
147+
MainActor.assumeIsolated {
148+
self?.handleDismiss(reason: .windowResignKey)
149+
}
150+
}
151+
}
152+
153+
outsideClickMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in
154+
MainActor.assumeIsolated {
155+
self?.handleOutsideClick(event: event)
156+
}
157+
return event
158+
}
159+
}
160+
161+
private func removeDismissObservers() {
162+
if let observer = scrollObserver {
163+
NotificationCenter.default.removeObserver(observer)
164+
scrollObserver = nil
165+
}
166+
if let observer = columnResizeObserver {
167+
NotificationCenter.default.removeObserver(observer)
168+
columnResizeObserver = nil
169+
}
170+
if let observer = appResignObserver {
171+
NotificationCenter.default.removeObserver(observer)
172+
appResignObserver = nil
173+
}
174+
if let observer = windowResignKeyObserver {
175+
NotificationCenter.default.removeObserver(observer)
176+
windowResignKeyObserver = nil
177+
}
178+
if let monitor = outsideClickMonitor {
179+
NSEvent.removeMonitor(monitor)
180+
outsideClickMonitor = nil
181+
}
182+
}
183+
184+
private func handleOutsideClick(event: NSEvent) {
185+
guard let containerView = container,
186+
let containerWindow = containerView.window,
187+
event.window === containerWindow else { return }
188+
let frameInWindow = containerView.convert(containerView.bounds, to: nil)
189+
if !frameInWindow.contains(event.locationInWindow) {
190+
handleDismiss(reason: .outsideClick)
191+
}
192+
}
193+
}
194+
195+
final class CellOverlayContainerView: NSView {
196+
override var isFlipped: Bool { true }
197+
}

0 commit comments

Comments
 (0)