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
Original file line number Diff line number Diff line change
Expand Up @@ -100,12 +100,16 @@ public struct SourceEditor: NSViewControllerRepresentable {
public typealias NSViewControllerType = TextViewController

public func makeNSViewController(context: Context) -> TextViewController {
makeController(coordinator: context.coordinator)
}

func makeController(coordinator: Coordinator) -> TextViewController {
let controller = TextViewController(
string: "",
language: language,
configuration: configuration,
cursorPositions: state.cursorPositions ?? [],
highlightProviders: context.coordinator.highlightProviders,
highlightProviders: coordinator.highlightProviders,
foldProvider: foldProvider,
undoManager: undoManager,
coordinators: coordinators,
Expand All @@ -124,7 +128,7 @@ public struct SourceEditor: NSViewControllerRepresentable {
controller.setCursorPositions(state.cursorPositions ?? [])
}

context.coordinator.setController(controller)
coordinator.setController(controller)
return controller
}

Expand Down Expand Up @@ -152,35 +156,38 @@ public struct SourceEditor: NSViewControllerRepresentable {
}

public func updateNSViewController(_ controller: TextViewController, context: Context) {
update(controller, coordinator: context.coordinator)
}

func update(_ controller: TextViewController, coordinator: Coordinator) {
controller.completionDelegate = completionDelegate

context.coordinator.updateHighlightProviders(highlightProviders)
coordinator.updateHighlightProviders(highlightProviders)

context.coordinator.textSync.text = text
coordinator.textSync.text = text
if case .binding(let binding) = text {
context.coordinator.textSync.applyRepresentableText(binding.wrappedValue, controller: controller)
coordinator.textSync.applyRepresentableText(binding.wrappedValue, controller: controller)
}

if !context.coordinator.phase.consumePendingEditorChange() {
context.coordinator.phase.applyRepresentableValue {
updateControllerWithState(state, controller: controller, coordinator: context.coordinator)
if !coordinator.phase.consumePendingEditorChange() {
coordinator.phase.applyRepresentableValue {
updateControllerWithState(state, controller: controller, coordinator: coordinator)
}
}

// Do manual diffing to reduce the amount of reloads.
// This helps a lot in view performance, as it otherwise gets triggered on each environment change.
guard !paramsAreEqual(controller: controller, coordinator: context.coordinator) else {
guard !paramsAreEqual(controller: controller, coordinator: coordinator) else {
return
}

if controller.language != language {
controller.language = language
}
controller.configuration = configuration
updateHighlighting(controller, coordinator: context.coordinator)
updateHighlighting(controller, coordinator: coordinator)

controller.reloadUI()
return
}

/// Compares against ``Coordinator/lastSyncedState``, never the editor's live state, which runs ahead of the binding
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//
// SourceEditorHost.swift
// TableProEditorKit
//

import AppKit
import SwiftUI

/// ``SourceEditor`` for an AppKit container: the same controller, two-way sync and teardown, with no SwiftUI host
/// around the editor.
@MainActor
public final class SourceEditorHost {
public let controller: TextViewController
/// Runs when the editor writes its state, before the write is applied back, as `onChange` does in SwiftUI.
public var onStateChange: ((_ old: SourceEditorState, _ new: SourceEditorState) -> Void)?

private let box: StateBox
private let coordinator: SourceEditor.Coordinator
private var editor: SourceEditor

public var state: SourceEditorState { box.state }

public init(state: SourceEditorState = SourceEditorState(), _ makeEditor: (Binding<SourceEditorState>) -> SourceEditor) {
let box = StateBox(state: state)
let editor = makeEditor(box.binding)
let coordinator = editor.makeCoordinator()
self.box = box
self.editor = editor
self.coordinator = coordinator
controller = editor.makeController(coordinator: coordinator)
box.onWrite = { [weak self] old, new in self?.stateDidChange(from: old, to: new) }
}

public func update(_ makeEditor: (Binding<SourceEditorState>) -> SourceEditor) {
editor = makeEditor(box.binding)
editor.update(controller, coordinator: coordinator)
}

public func dismantle() {
SourceEditor.dismantleNSViewController(controller, coordinator: coordinator)
}

// A SwiftUI state write leads to an update pass that consumes the editor's change latch; without one the latch
// would swallow the next text the owner pushes.
private func stateDidChange(from old: SourceEditorState, to new: SourceEditorState) {
onStateChange?(old, new)
editor.update(controller, coordinator: coordinator)
}
}

@MainActor
private final class StateBox {
var state: SourceEditorState
var onWrite: ((SourceEditorState, SourceEditorState) -> Void)?

init(state: SourceEditorState) {
self.state = state
}

var binding: Binding<SourceEditorState> {
Binding(
get: { [weak self] in self?.state ?? SourceEditorState() },
set: { [weak self] newValue in
guard let self else { return }
let old = state
state = newValue
onWrite?(old, newValue)
}
)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
//
// SourceEditorHostTests.swift
// TableProEditorKitTests
//
// An AppKit container gets the editor with the same two-way sync SwiftUI gives it. A state write the editor makes
// latches its change; under SwiftUI the write's update pass consumes the latch, and here the host has to.
//

import AppKit
import SwiftUI
@testable import TableProEditorKit
import Testing

@MainActor
private final class Owner {
var text: String
var reportedStates: [SourceEditorState] = []
let coordinator: RecordingCoordinator

init(text: String, coordinator: RecordingCoordinator = RecordingCoordinator()) {
self.text = text
self.coordinator = coordinator
}

var textBinding: Binding<String> {
Binding(get: { self.text }, set: { self.text = $0 })
}

func editor(_ state: Binding<SourceEditorState>) -> SourceEditor {
SourceEditor(
textBinding,
language: .default,
configuration: Mock.config(),
state: state,
highlightProviders: [],
coordinators: [coordinator]
)
}
}

private final class RecordingCoordinator: TextViewCoordinator {
private(set) var destroyCount = 0

func prepareCoordinator(controller: TextViewController) {}

func destroy() {
destroyCount += 1
}
}

@MainActor
struct SourceEditorHostTests {
private func mount(_ owner: Owner) -> (SourceEditorHost, NSWindow) {
let host = SourceEditorHost { owner.editor($0) }
host.onStateChange = { _, new in owner.reportedStates.append(new) }
let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 600, height: 400),
styleMask: [.titled],
backing: .buffered,
defer: false
)
window.isReleasedWhenClosed = false
window.contentView = host.controller.view
window.contentView?.layoutSubtreeIfNeeded()
return (host, window)
}

@Test("The owner's text reaches the editor, and an edit reaches the owner")
func textSyncsBothWays() {
let owner = Owner(text: "SELECT 1")
let (host, window) = mount(owner)
defer { window.orderOut(nil) }
#expect(host.controller.textView.string == "SELECT 1")

owner.text = "SELECT 2"
host.update { owner.editor($0) }
#expect(host.controller.textView.string == "SELECT 2")

host.controller.textView.replaceCharacters(in: NSRange(location: 8, length: 0), with: "0")
#expect(owner.text == "SELECT 20")
}

@Test("A caret the editor reports is passed on and does not swallow the owner's next text")
func stateWriteKeepsTheNextTextPush() {
let owner = Owner(text: "SELECT 1")
let (host, window) = mount(owner)
defer { window.orderOut(nil) }

let caret = NSRange(location: 3, length: 0)
host.controller.setCursorPositions([CursorPosition(range: caret)])
let held = host.state.cursorPositions?.map { $0.range }
let reported = owner.reportedStates.last?.cursorPositions?.map { $0.range }
#expect(held == [caret])
#expect(reported == [caret])

owner.text = "SELECT 42"
host.update { owner.editor($0) }
#expect(host.controller.textView.string == "SELECT 42")
}

@Test("dismantle destroys each text coordinator once")
func dismantleDestroysCoordinatorsOnce() {
let owner = Owner(text: "SELECT 1")
let (host, window) = mount(owner)
defer { window.orderOut(nil) }

host.dismantle()

#expect(owner.coordinator.destroyCount == 1)
#expect(host.controller.textCoordinators.values().isEmpty)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ internal final class SidebarContainerViewController: NSViewController {
/// Which list the rows below show. Window chrome like the field under it, so it stands through
/// a connection switch and follows the connection on screen.
private let scopeControl = SidebarScopeControl()
private let searchField = NSSearchField()
private let searchField = SidebarFilterField()
/// Sidebar chrome, like the field it shares a row with, so it survives a connection switch and
/// writes settings that are not scoped to one. Hidden on the Favorites tab, whose list draws
/// none of what these options settle.
Expand Down Expand Up @@ -121,6 +121,7 @@ internal final class SidebarContainerViewController: NSViewController {
searchField.controlSize = .regular
searchField.sendsSearchStringImmediately = true
searchField.delegate = self
searchField.onFocus = { [weak self] in self?.writeFilterFocus(true) }
searchField.setAccessibilityIdentifier("sidebar-filter")
searchField.setAccessibilityLabel(String(localized: "Filter"))

Expand Down Expand Up @@ -234,6 +235,8 @@ internal final class SidebarContainerViewController: NSViewController {
/// being repointed at a different one.
filterPopover?.close()
filterPopover = nil
// The connection the field leaves would never hear its editing end.
if state !== sidebarState { writeFilterFocus(false) }
self.sidebarState = state
guard let state else {
/// `syncFromState` is the only writer of these, and it cannot run without a state, so
Expand Down Expand Up @@ -328,6 +331,10 @@ extension SidebarContainerViewController: NSSearchFieldDelegate {
writeSearchText("")
}

func controlTextDidEndEditing(_ obj: Notification) {
writeFilterFocus(false)
}

/// Down from the filter field hands focus to the list.
///
/// It names the list rather than asking the key view loop for whatever comes next. With the
Expand All @@ -350,6 +357,24 @@ extension SidebarContainerViewController: NSSearchFieldDelegate {
sidebarState.favoritesSearchText = text
}
}

// Favorites filters its own list and never reads the all-schema listing, so only Tables marks focus.
private func writeFilterFocus(_ isFocused: Bool) {
guard let sidebarState, sidebarState.isFilterFieldFocused != isFocused else { return }
guard !isFocused || sidebarState.selectedSidebarTab == .tables else { return }
sidebarState.isFilterFieldFocused = isFocused
}
}

// The delegate hears of editing only from the first keystroke, and a search starts at focus.
private final class SidebarFilterField: NSSearchField {
var onFocus: (() -> Void)?

override func becomeFirstResponder() -> Bool {
let became = super.becomeFirstResponder()
if became { onFocus?() }
return became
}
}

private final class ObservationContinuationBox: @unchecked Sendable {
Expand Down
1 change: 1 addition & 0 deletions apple/TablePro/Models/UI/SharedSidebarState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ final class SharedSidebarState: ObservableObject {

@Published var searchText: String = ""
@Published var favoritesSearchText: String = ""
@Published var isFilterFieldFocused = false

@Published var recentTables: [RecentTableEntry] = []

Expand Down
30 changes: 27 additions & 3 deletions apple/TablePro/ViewModels/SidebarViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,8 @@ final class SidebarViewModel: ObservableObject {
searchTextObservation = sharedState.onMainActorChange { [weak self] in
guard let self else { return }
self.scheduleFilterQueryUpdate(oldValue: self.filterQuery)
// The schema filter lives there too, and an edit to it during a search selects another listing.
self.noteFilterFieldFocus()
// The field's focus and the schema filter live there too: focus starts a search, and a filter edit selects another listing.
self.loadAllSchemaTablesForSearch()
}
}
Expand All @@ -111,11 +112,33 @@ final class SidebarViewModel: ObservableObject {
invalidateFilterCaches()
if filterQuery.isEmpty { listingMatchesMemo.removeAll() }
// Once per search, not per keystroke: a failed or partly unlisted listing was read again on every refinement.
if oldValue.isEmpty { listingDemand.reset() }
if oldValue.isEmpty {
if searchStartedAtFocus {
searchStartedAtFocus = false
} else {
listingDemand.reset()
}
}
loadAllSchemaTablesForSearch()
}
}

private var wasFilterFieldFocused = false
// A search starts at focus or at its first character, whichever comes first, and asks for a listing once.
private var searchStartedAtFocus = false

private func noteFilterFieldFocus() {
let isFocused = sharedState.isFilterFieldFocused
guard isFocused != wasFilterFieldFocused else { return }
wasFilterFieldFocused = isFocused
if isFocused, filterQuery.isEmpty {
listingDemand.reset()
searchStartedAtFocus = true
} else if !isFocused {
searchStartedAtFocus = false
}
}

private var listingMatchesMemo = DatabaseTreeFilter.SchemaListingMatchesMemo()

private var listingDemand = AllSchemaTablesDemand()
Expand Down Expand Up @@ -570,7 +593,8 @@ final class SidebarViewModel: ObservableObject {
/// for them. It is asked for here rather than by the outline, because a flat list with no local
/// match shows "No Results" in place of the outline, which then never sees the search at all.
private func loadAllSchemaTablesForSearch() {
guard !filterQuery.isEmpty else { return }
// A focused field is a search about to start, and its first keystroke would otherwise wait out the read.
guard !filterQuery.isEmpty || sharedState.isFilterFieldFocused else { return }
let service = DatabaseTreeMetadataService.shared
let connectionId = connectionId
loadAttachedSchemaObjectsForSearch(service: service)
Expand Down
Loading
Loading