Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- The JSON field in the row details inspector can now be resized by dragging the handle below it. The height is remembered across rows and app restarts. (#1849)

### Changed

- Data grid column comments now appear directly in column headers when object comments are enabled, instead of only being available from the header tooltip. (#1789)
Expand Down
62 changes: 33 additions & 29 deletions TablePro/Views/RightSidebar/FieldEditors/JsonEditorView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ internal struct JsonEditorView: View {
var onPopOut: ((String) -> Void)?

@State private var displayText: String
@AppStorage("rightSidebar.jsonFieldHeight") private var fieldHeight = ResizableFieldMetrics.defaultJsonHeight

init(context: FieldEditorContext, onExpand: (() -> Void)? = nil, onPopOut: ((String) -> Void)? = nil) {
self.context = context
Expand All @@ -20,37 +21,40 @@ internal struct JsonEditorView: View {
}

var body: some View {
JSONCodeEditor(text: $displayText, isEditable: !context.isReadOnly)
.frame(minHeight: context.isReadOnly ? 60 : 80, maxHeight: 120)
.clipShape(RoundedRectangle(cornerRadius: 5))
.overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor)))
.overlay(alignment: .bottomTrailing) {
HStack(spacing: 2) {
if let onPopOut {
Button { onPopOut(displayText) } label: {
Image(systemName: "arrow.up.forward.app")
.font(.caption2)
.padding(4)
.themeMaterial(.inlineControl, .ultraThinMaterial, in: RoundedRectangle(cornerRadius: 4))
}
.buttonStyle(.borderless)
.help(String(localized: "Open in Window"))
}
if let onExpand {
Button(action: onExpand) {
Image(systemName: "arrow.up.left.and.arrow.down.right")
.font(.caption2)
.padding(4)
.themeMaterial(.inlineControl, .ultraThinMaterial, in: RoundedRectangle(cornerRadius: 4))
}
.buttonStyle(.borderless)
.help(String(localized: "Expand in Sidebar"))
}
ResizableEditorContainer(height: $fieldHeight, range: ResizableFieldMetrics.jsonHeightRange) {
JSONCodeEditor(text: $displayText, isEditable: !context.isReadOnly)
.clipShape(RoundedRectangle(cornerRadius: 5))
.overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color(nsColor: .separatorColor)))
.overlay(alignment: .bottomTrailing) { actionButtons }
}
.onChange(of: displayText) { propagateEdit() }
.onChange(of: context.value.wrappedValue) { syncFromBinding() }
}

private var actionButtons: some View {
HStack(spacing: 2) {
if let onPopOut {
Button { onPopOut(displayText) } label: {
Image(systemName: "arrow.up.forward.app")
.font(.caption2)
.padding(4)
.themeMaterial(.inlineControl, .ultraThinMaterial, in: RoundedRectangle(cornerRadius: 4))
}
.buttonStyle(.borderless)
.help(String(localized: "Open in Window"))
}
if let onExpand {
Button(action: onExpand) {
Image(systemName: "arrow.up.left.and.arrow.down.right")
.font(.caption2)
.padding(4)
.themeMaterial(.inlineControl, .ultraThinMaterial, in: RoundedRectangle(cornerRadius: 4))
}
.padding(4)
.buttonStyle(.borderless)
.help(String(localized: "Expand in Sidebar"))
}
.onChange(of: displayText) { propagateEdit() }
.onChange(of: context.value.wrappedValue) { syncFromBinding() }
}
.padding(4)
}

private func propagateEdit() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
//
// ResizableEditorContainer.swift
// TablePro
//

import AppKit
import SwiftUI

internal struct ResizableEditorContainer<Content: View>: View {
@Binding var height: Double
let range: ClosedRange<Double>
@ViewBuilder let content: () -> Content

@GestureState private var liveDelta: Double = 0
@State private var isHandleHovered = false

private var resolvedHeight: Double {
ResizableFieldMetrics.resolve(base: height, delta: liveDelta, range: range)
}

var body: some View {
VStack(spacing: 2) {
content()
.frame(height: resolvedHeight)
resizeHandle
}
}

private var resizeHandle: some View {
Capsule()
.fill(Color(nsColor: .tertiaryLabelColor))
.frame(width: 26, height: 4)
.opacity(isHandleHovered ? 1 : 0.5)
.frame(maxWidth: .infinity, minHeight: 11)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 1, coordinateSpace: .global)
.updating($liveDelta) { value, state, _ in
state = value.translation.height
}
.onEnded { value in
height = ResizableFieldMetrics.resolve(
base: height,
delta: value.translation.height,
range: range
)
}
)
.onHover { hovering in
guard hovering != isHandleHovered else { return }
isHandleHovered = hovering
if hovering {
NSCursor.resizeUpDown.push()
} else {
NSCursor.pop()
}
}
.onDisappear {
if isHandleHovered {
NSCursor.pop()
isHandleHovered = false
}
}
.accessibilityHidden(true)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
//
// ResizableFieldMetrics.swift
// TablePro
//

import Foundation

internal enum ResizableFieldMetrics {
static let jsonHeightRange: ClosedRange<Double> = 80...600
static let defaultJsonHeight: Double = 120

static func resolve(base: Double, delta: Double, range: ClosedRange<Double>) -> Double {
min(max(base + delta, range.lowerBound), range.upperBound)
}
}
37 changes: 37 additions & 0 deletions TableProTests/Views/RightSidebar/ResizableFieldMetricsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//
// ResizableFieldMetricsTests.swift
// TableProTests
//

import XCTest

@testable import TablePro

final class ResizableFieldMetricsTests: XCTestCase {
private let range: ClosedRange<Double> = 80...600

func testResolveAddsDeltaWithinRange() {
XCTAssertEqual(ResizableFieldMetrics.resolve(base: 120, delta: 40, range: range), 160)
}

func testResolveClampsToLowerBound() {
XCTAssertEqual(ResizableFieldMetrics.resolve(base: 100, delta: -200, range: range), 80)
}

func testResolveClampsToUpperBound() {
XCTAssertEqual(ResizableFieldMetrics.resolve(base: 500, delta: 400, range: range), 600)
}

func testResolveNegativeDeltaStaysWithinRange() {
XCTAssertEqual(ResizableFieldMetrics.resolve(base: 300, delta: -100, range: range), 200)
}

func testResolveHonoursExactBounds() {
XCTAssertEqual(ResizableFieldMetrics.resolve(base: 80, delta: 0, range: range), 80)
XCTAssertEqual(ResizableFieldMetrics.resolve(base: 600, delta: 0, range: range), 600)
}

func testDefaultJsonHeightIsWithinJsonRange() {
XCTAssertTrue(ResizableFieldMetrics.jsonHeightRange.contains(ResizableFieldMetrics.defaultJsonHeight))
}
}
2 changes: 2 additions & 0 deletions docs/features/data-grid.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ See [Change Tracking](/features/change-tracking) for details.

Toggle the inspector with `Cmd+Shift+B`. With a row selected, it lists every column value with a full editor for long text and JSON. With no row selected, it shows table metadata: row count, size, engine, charset, and collation.

Drag the handle below a JSON field to make it taller and see more content without leaving the inspector. The height is remembered across rows and app restarts. For very large values, use **Expand in Sidebar** or **Open in Window**.

<Frame caption="Cell Inspector">
<img className="block dark:hidden" src="/images/cell-inspector.png" alt="Cell Inspector" />
<img className="hidden dark:block" src="/images/cell-inspector-dark.png" alt="Cell Inspector" />
Expand Down
Loading