diff --git a/CHANGELOG.md b/CHANGELOG.md index 5351b5a3b..7214be899 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Opening the connection or database switcher now puts the cursor in its search field even while a filter input is being edited; the filter text is kept. (#1575) - TablePro no longer shows its icon for .sql, .sqlite, and .duckdb files in Finder when it is not the default app for those types. (#1594) - The JSON results view shows row data right away instead of staying blank until you switch between Tree and Text, and it updates when the row selection changes. A spinner shows while large results are being formatted. (#1576) - Double-clicking or pressing Enter on a JSON cell now edits its value inline, like other cells; on a blob cell it opens the hex editor. The chevron still opens the tree or hex editor. Neither cell responded to double-click before. (#1588) diff --git a/TablePro/Views/Filter/FilterValueTextField.swift b/TablePro/Views/Filter/FilterValueTextField.swift index 9ed6527b5..ef1be7b97 100644 --- a/TablePro/Views/Filter/FilterValueTextField.swift +++ b/TablePro/Views/Filter/FilterValueTextField.swift @@ -12,6 +12,24 @@ enum FilterCompletionSource { case sqlTokens(RawSQLFilterCompletionProvider) } +struct FilterFocusState: Equatable { + private(set) var claimedId: UUID? + + mutating func claimFocus(requestedId: UUID?, identity: UUID) -> Bool { + guard requestedId == identity, claimedId != identity else { return false } + claimedId = identity + return true + } + + mutating func markFocused(_ identity: UUID) { + claimedId = identity + } + + mutating func releaseFocus() { + claimedId = nil + } +} + struct FilterValueTextField: NSViewRepresentable { @Binding var text: String @Binding var focusedId: UUID? @@ -132,6 +150,8 @@ struct FilterValueTextField: NSViewRepresentable { private let suggestionState = SuggestionState() private var suggestionPopover: NSPopover? private var keyMonitor: Any? + private var focusState = FilterFocusState() + private var windowKeyObserver: NSObjectProtocol? private var latestReplacementRange: NSRange? private var completionGeneration = 0 private static let completionDebounce: UInt64 = 50_000_000 @@ -158,30 +178,54 @@ struct FilterValueTextField: NSViewRepresentable { } func focusIfRequested() { - guard focusedId.wrappedValue == identity, - let textField, - let window = textField.window else { return } + guard let textField, + let window = textField.window, + focusState.claimFocus(requestedId: focusedId.wrappedValue, identity: identity) else { return } if window.firstResponder !== textField.currentEditor() { window.makeFirstResponder(textField) } } func handleBecameFirstResponder() { + focusState.markFocused(identity) if focusedId.wrappedValue != identity { focusedId.wrappedValue = identity } } func handleResignedFirstResponder() { + focusState.releaseFocus() if focusedId.wrappedValue == identity { focusedId.wrappedValue = nil } } + func startObservingWindowKeyStatus(for window: NSWindow) { + stopObservingWindowKeyStatus() + windowKeyObserver = NotificationCenter.default.addObserver( + forName: NSWindow.didResignKeyNotification, + object: window, + queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + self?.handleResignedFirstResponder() + } + } + } + + func stopObservingWindowKeyStatus() { + guard let token = windowKeyObserver else { return } + NotificationCenter.default.removeObserver(token) + windowKeyObserver = nil + } + deinit { if let token = keyMonitor { NSEvent.removeMonitor(token) } + if let token = windowKeyObserver { + NotificationCenter.default.removeObserver(token) + } } func controlTextDidChange(_ notification: Notification) { @@ -417,6 +461,11 @@ struct FilterValueTextField: NSViewRepresentable { override func viewDidMoveToWindow() { super.viewDidMoveToWindow() + if let window { + owner?.startObservingWindowKeyStatus(for: window) + } else { + owner?.stopObservingWindowKeyStatus() + } owner?.focusIfRequested() } diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index fef03b317..1d0de4c27 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -852,6 +852,7 @@ final class MainContentCommandActions { let type = coordinator.connection.type guard PluginManager.shared.supportsDatabaseSwitching(for: type) else { return } guard PluginManager.shared.connectionMode(for: type) != .fileBased else { return } + coordinator.contentWindow?.makeFirstResponder(nil) coordinator.isDatabaseSwitcherShown = true } @@ -860,6 +861,7 @@ final class MainContentCommandActions { } func openConnectionSwitcher() { + coordinator?.contentWindow?.makeFirstResponder(nil) coordinator?.isConnectionSwitcherShown = true } diff --git a/TablePro/Views/Sidebar/NativeSearchField.swift b/TablePro/Views/Sidebar/NativeSearchField.swift index adb3e8211..be1498164 100644 --- a/TablePro/Views/Sidebar/NativeSearchField.swift +++ b/TablePro/Views/Sidebar/NativeSearchField.swift @@ -9,10 +9,21 @@ import AppKit import SwiftUI private final class IntrinsicHeightSearchField: NSSearchField { + var focusOnAppear = false + override var intrinsicContentSize: NSSize { let cellHeight = cell?.cellSize.height ?? super.intrinsicContentSize.height return NSSize(width: NSView.noIntrinsicMetric, height: cellHeight) } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + guard focusOnAppear, acceptsFirstResponder, let window else { return } + window.makeFirstResponder(self) + if !window.isKeyWindow { + window.makeKey() + } + } } struct NativeSearchField: NSViewRepresentable { @@ -39,11 +50,7 @@ struct NativeSearchField: NSViewRepresentable { field.widthAnchor.constraint(lessThanOrEqualToConstant: maxWidth).isActive = true } context.coordinator.lastFocusTrigger = focusTrigger - if focusOnAppear { - DispatchQueue.main.async { - field.window?.makeFirstResponder(field) - } - } + field.focusOnAppear = focusOnAppear return field } diff --git a/TableProTests/Views/Filter/FilterFocusStateTests.swift b/TableProTests/Views/Filter/FilterFocusStateTests.swift new file mode 100644 index 000000000..67e4570a6 --- /dev/null +++ b/TableProTests/Views/Filter/FilterFocusStateTests.swift @@ -0,0 +1,73 @@ +// +// FilterFocusStateTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("Filter Focus State") +struct FilterFocusStateTests { + @Test("Claims focus when requested id matches identity") + func testClaimFocus_newRequestClaims() { + var state = FilterFocusState() + let identity = UUID() + let claimed = state.claimFocus(requestedId: identity, identity: identity) + #expect(claimed) + #expect(state.claimedId == identity) + } + + @Test("Does not re-claim focus it already owns") + func testClaimFocus_repeatedRequestDoesNotReclaim() { + var state = FilterFocusState() + let identity = UUID() + _ = state.claimFocus(requestedId: identity, identity: identity) + let reclaimed = state.claimFocus(requestedId: identity, identity: identity) + #expect(!reclaimed) + } + + @Test("Does not claim focus requested for another identity") + func testClaimFocus_otherIdentityDoesNotClaim() { + var state = FilterFocusState() + let claimed = state.claimFocus(requestedId: UUID(), identity: UUID()) + #expect(!claimed) + #expect(state.claimedId == nil) + } + + @Test("Does not claim focus when nothing is requested") + func testClaimFocus_nilRequestDoesNotClaim() { + var state = FilterFocusState() + let claimed = state.claimFocus(requestedId: nil, identity: UUID()) + #expect(!claimed) + } + + @Test("Releasing focus allows a later re-claim") + func testReleaseFocus_allowsReclaim() { + var state = FilterFocusState() + let identity = UUID() + _ = state.claimFocus(requestedId: identity, identity: identity) + state.releaseFocus() + #expect(state.claimedId == nil) + let reclaimed = state.claimFocus(requestedId: identity, identity: identity) + #expect(reclaimed) + } + + @Test("Click-driven focus suppresses a redundant programmatic claim") + func testMarkFocused_suppressesRedundantClaim() { + var state = FilterFocusState() + let identity = UUID() + state.markFocused(identity) + let claimed = state.claimFocus(requestedId: identity, identity: identity) + #expect(!claimed) + } + + @Test("Focus marked on another identity does not block this identity") + func testMarkFocused_otherIdentityDoesNotBlockClaim() { + var state = FilterFocusState() + let identity = UUID() + state.markFocused(UUID()) + let claimed = state.claimFocus(requestedId: identity, identity: identity) + #expect(claimed) + } +}