diff --git a/CHANGELOG.md b/CHANGELOG.md index d1b223f8c..0f7f00176 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use semantic selected-text color instead of hardcoded white in selected rows - Use proper CommandGroup for full-screen shortcut instead of event monitor - Use sheet presentation for all file open/save panels instead of free-floating dialogs +- Replace event monitor with native SwiftUI .onKeyPress() in connection switcher +- Extract reusable SearchFieldView component from 4 custom search field implementations +- Replace custom resize handle with native NSSplitView for inspector panel ### Added diff --git a/TablePro/ContentView.swift b/TablePro/ContentView.swift index ac8772991..becd20620 100644 --- a/TablePro/ContentView.swift +++ b/TablePro/ContentView.swift @@ -226,7 +226,13 @@ struct ContentView: View { } detail: { // MARK: - Detail (Main workspace with optional right sidebar) if let currentSession = currentSession, let rightPanelState, let sessionState { - HStack(spacing: 0) { + HorizontalSplitView( + isTrailingCollapsed: !rightPanelState.isPresented, + trailingWidth: Bindable(rightPanelState).panelWidth, + minTrailingWidth: RightPanelState.minWidth, + maxTrailingWidth: RightPanelState.maxWidth, + autosaveName: "InspectorSplit" + ) { MainContentView( connection: currentSession.connection, payload: payload, @@ -244,23 +250,15 @@ struct ContentView: View { toolbarState: sessionState.toolbarState, coordinator: sessionState.coordinator ) - .frame(maxWidth: .infinity) - - if rightPanelState.isPresented { - PanelResizeHandle(panelWidth: Bindable(rightPanelState).panelWidth) - Divider() - UnifiedRightPanelView( - state: rightPanelState, - inspectorContext: inspectorContext, - connection: currentSession.connection, - tables: currentSession.tables - ) - .frame(width: rightPanelState.panelWidth) - .background(Color(nsColor: .windowBackgroundColor)) - .transition(.move(edge: .trailing)) - } + } trailing: { + UnifiedRightPanelView( + state: rightPanelState, + inspectorContext: inspectorContext, + connection: currentSession.connection, + tables: currentSession.tables + ) + .background(Color(nsColor: .windowBackgroundColor)) } - .animation(.easeInOut(duration: 0.2), value: rightPanelState.isPresented) } else { VStack(spacing: 16) { ProgressView() @@ -431,10 +429,6 @@ struct ContentView: View { } } - private func saveCurrentSessionState() { - // State is automatically saved through bindings - } - // MARK: - Persistence private func deleteConnection(_ connection: DatabaseConnection) { diff --git a/TablePro/Views/Components/HorizontalSplitView.swift b/TablePro/Views/Components/HorizontalSplitView.swift new file mode 100644 index 000000000..2217fdada --- /dev/null +++ b/TablePro/Views/Components/HorizontalSplitView.swift @@ -0,0 +1,143 @@ +// +// HorizontalSplitView.swift +// TablePro +// + +import AppKit +import SwiftUI + +struct HorizontalSplitView: NSViewRepresentable { + var isTrailingCollapsed: Bool + @Binding var trailingWidth: CGFloat + var minTrailingWidth: CGFloat + var maxTrailingWidth: CGFloat + var autosaveName: String + @ViewBuilder var leading: Leading + @ViewBuilder var trailing: Trailing + + func makeCoordinator() -> Coordinator { + Coordinator(trailingWidth: $trailingWidth) + } + + func makeNSView(context: Context) -> NSSplitView { + let splitView = NSSplitView() + splitView.isVertical = true + splitView.dividerStyle = .thin + splitView.autosaveName = autosaveName + splitView.delegate = context.coordinator + + let leadingHosting = NSHostingView(rootView: leading) + leadingHosting.sizingOptions = [.minSize] + + let trailingHosting = NSHostingView(rootView: trailing) + trailingHosting.sizingOptions = [.minSize] + + splitView.addArrangedSubview(leadingHosting) + splitView.addArrangedSubview(trailingHosting) + + context.coordinator.leadingHosting = leadingHosting + context.coordinator.trailingHosting = trailingHosting + context.coordinator.lastCollapsedState = isTrailingCollapsed + context.coordinator.minWidth = minTrailingWidth + context.coordinator.maxWidth = maxTrailingWidth + + if isTrailingCollapsed { + trailingHosting.isHidden = true + } + + return splitView + } + + func updateNSView(_ splitView: NSSplitView, context: Context) { + context.coordinator.leadingHosting?.rootView = leading + context.coordinator.trailingHosting?.rootView = trailing + context.coordinator.trailingWidth = $trailingWidth + context.coordinator.minWidth = minTrailingWidth + context.coordinator.maxWidth = maxTrailingWidth + + guard let trailingView = context.coordinator.trailingHosting else { return } + let wasCollapsed = context.coordinator.lastCollapsedState + + if isTrailingCollapsed != wasCollapsed { + context.coordinator.lastCollapsedState = isTrailingCollapsed + if isTrailingCollapsed { + if splitView.subviews.count >= 2 { + context.coordinator.savedDividerPosition = splitView.subviews[1].frame.width + } + splitView.setPosition(splitView.bounds.width, ofDividerAt: 0) + trailingView.isHidden = true + } else { + trailingView.isHidden = false + let targetWidth = context.coordinator.savedDividerPosition ?? trailingWidth + splitView.adjustSubviews() + splitView.setPosition(splitView.bounds.width - targetWidth, ofDividerAt: 0) + } + } + } + + final class Coordinator: NSObject, NSSplitViewDelegate { + var leadingHosting: NSHostingView? + var trailingHosting: NSHostingView? + var lastCollapsedState = false + var savedDividerPosition: CGFloat? + var minWidth: CGFloat = 0 + var maxWidth: CGFloat = 0 + var trailingWidth: Binding + + init(trailingWidth: Binding) { + self.trailingWidth = trailingWidth + } + + func splitView( + _ splitView: NSSplitView, + constrainMinCoordinate proposedMinimumPosition: CGFloat, + ofSubviewAt dividerIndex: Int + ) -> CGFloat { + splitView.bounds.width - maxWidth + } + + func splitView( + _ splitView: NSSplitView, + constrainMaxCoordinate proposedMaximumPosition: CGFloat, + ofSubviewAt dividerIndex: Int + ) -> CGFloat { + splitView.bounds.width - minWidth + } + + func splitView( + _ splitView: NSSplitView, + canCollapseSubview subview: NSView + ) -> Bool { + subview == trailingHosting + } + + func splitView( + _ splitView: NSSplitView, + effectiveRect proposedEffectiveRect: NSRect, + forDrawnRect drawnRect: NSRect, + ofDividerAt dividerIndex: Int + ) -> NSRect { + if trailingHosting?.isHidden == true { + return .zero + } + return proposedEffectiveRect + } + + func splitView( + _ splitView: NSSplitView, + shouldHideDividerAt dividerIndex: Int + ) -> Bool { + trailingHosting?.isHidden == true + } + + func splitViewDidResizeSubviews(_ notification: Notification) { + guard let splitView = notification.object as? NSSplitView, + splitView.subviews.count >= 2, + trailingHosting?.isHidden != true + else { return } + let width = splitView.subviews[1].frame.width + guard width > 0, abs(width - trailingWidth.wrappedValue) > 0.5 else { return } + trailingWidth.wrappedValue = width + } + } +} diff --git a/TablePro/Views/Components/PanelResizeHandle.swift b/TablePro/Views/Components/PanelResizeHandle.swift deleted file mode 100644 index 6f8531c6b..000000000 --- a/TablePro/Views/Components/PanelResizeHandle.swift +++ /dev/null @@ -1,40 +0,0 @@ -// -// PanelResizeHandle.swift -// TablePro -// -// Draggable resize handle for the right panel. -// - -import SwiftUI - -struct PanelResizeHandle: View { - @Binding var panelWidth: CGFloat - - @State private var isDragging = false - - var body: some View { - Rectangle() - .fill(Color.clear) - .frame(width: 5) - .contentShape(Rectangle()) - .onHover { hovering in - if hovering { - NSCursor.resizeLeftRight.push() - } else { - NSCursor.pop() - } - } - .gesture( - DragGesture(minimumDistance: 1) - .onChanged { value in - isDragging = true - // Dragging left increases panel width (handle is on the leading edge) - let newWidth = panelWidth - value.translation.width - panelWidth = min(max(newWidth, RightPanelState.minWidth), RightPanelState.maxWidth) - } - .onEnded { _ in - isDragging = false - } - ) - } -} diff --git a/TablePro/Views/Components/SearchFieldView.swift b/TablePro/Views/Components/SearchFieldView.swift new file mode 100644 index 000000000..5b9770e4c --- /dev/null +++ b/TablePro/Views/Components/SearchFieldView.swift @@ -0,0 +1,37 @@ +// +// SearchFieldView.swift +// TablePro +// + +import SwiftUI + +struct SearchFieldView: View { + let placeholder: String + @Binding var text: String + var fontSize: CGFloat? + + var body: some View { + let resolvedSize = fontSize ?? ThemeEngine.shared.activeTheme.typography.body + HStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .font(.system(size: resolvedSize)) + .foregroundStyle(.tertiary) + + TextField(placeholder, text: $text) + .textFieldStyle(.plain) + .font(.system(size: resolvedSize)) + + if !text.isEmpty { + Button { text = "" } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background(Color(nsColor: .controlBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } +} diff --git a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift index 91366f5c8..2ad78ed4e 100644 --- a/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift +++ b/TablePro/Views/DatabaseSwitcher/DatabaseSwitcherSheet.swift @@ -155,31 +155,13 @@ struct DatabaseSwitcherSheet: View { private var toolbar: some View { HStack(spacing: 8) { // Search - HStack(spacing: 6) { - Image(systemName: "magnifyingglass") - .font(.system(size: ThemeEngine.shared.activeTheme.typography.body)) - .foregroundStyle(.tertiary) - - TextField(isSchemaMode + SearchFieldView( + placeholder: isSchemaMode ? String(localized: "Search schemas...") : String(localized: "Search databases..."), - text: $viewModel.searchText) - .textFieldStyle(.plain) - .font(.system(size: ThemeEngine.shared.activeTheme.typography.body)) - .focused($focus, equals: .search) - - if !viewModel.searchText.isEmpty { - Button(action: { viewModel.searchText = "" }) { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal, 8) - .padding(.vertical, 6) - .background(Color(nsColor: .controlBackgroundColor)) - .cornerRadius(6) + text: $viewModel.searchText + ) + .focused($focus, equals: .search) // Refresh Button(action: { diff --git a/TablePro/Views/QuickSwitcher/QuickSwitcherView.swift b/TablePro/Views/QuickSwitcher/QuickSwitcherView.swift index 8b7a52429..2ecbcf385 100644 --- a/TablePro/Views/QuickSwitcher/QuickSwitcherView.swift +++ b/TablePro/Views/QuickSwitcher/QuickSwitcherView.swift @@ -95,28 +95,11 @@ internal struct QuickSwitcherSheet: View { // MARK: - Search Toolbar private var searchToolbar: some View { - HStack(spacing: 6) { - Image(systemName: "magnifyingglass") - .font(.system(size: ThemeEngine.shared.activeTheme.typography.body)) - .foregroundStyle(.tertiary) - - TextField("Search tables, views, databases...", text: $viewModel.searchText) - .textFieldStyle(.plain) - .font(.system(size: ThemeEngine.shared.activeTheme.typography.body)) - .focused($focus, equals: .search) - - if !viewModel.searchText.isEmpty { - Button(action: { viewModel.searchText = "" }) { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal, 8) - .padding(.vertical, 6) - .background(Color(nsColor: .controlBackgroundColor)) - .cornerRadius(6) + SearchFieldView( + placeholder: "Search tables, views, databases...", + text: $viewModel.searchText + ) + .focused($focus, equals: .search) .padding(.horizontal, 12) .padding(.vertical, 8) } diff --git a/TablePro/Views/RightSidebar/RightSidebarView.swift b/TablePro/Views/RightSidebar/RightSidebarView.swift index ff3527ff7..48d767f89 100644 --- a/TablePro/Views/RightSidebar/RightSidebarView.swift +++ b/TablePro/Views/RightSidebar/RightSidebarView.swift @@ -146,26 +146,12 @@ struct RightSidebarView: View { return VStack(spacing: 0) { // Inline search field - HStack(spacing: 6) { - Image(systemName: "magnifyingglass") - .foregroundStyle(.tertiary) - .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) - TextField("Search for field...", text: $searchText) - .textFieldStyle(.plain) - .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) - if !searchText.isEmpty { - Button { - searchText = "" - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.tertiary) - .font(.system(size: ThemeEngine.shared.activeTheme.typography.small)) - } - .buttonStyle(.plain) - } - } + SearchFieldView( + placeholder: "Search for field...", + text: $searchText, + fontSize: ThemeEngine.shared.activeTheme.typography.small + ) .padding(.horizontal, 10) - .padding(.vertical, 6) Divider() diff --git a/TablePro/Views/Settings/KeyboardSettingsView.swift b/TablePro/Views/Settings/KeyboardSettingsView.swift index 6fc2b0869..d716c4106 100644 --- a/TablePro/Views/Settings/KeyboardSettingsView.swift +++ b/TablePro/Views/Settings/KeyboardSettingsView.swift @@ -19,24 +19,10 @@ struct KeyboardSettingsView: View { var body: some View { VStack(spacing: 0) { // Search bar - HStack { - Image(systemName: "magnifyingglass") - .foregroundStyle(.secondary) - TextField("Search shortcuts...", text: $searchText) - .textFieldStyle(.plain) - if !searchText.isEmpty { - Button { - searchText = "" - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - } - } - .padding(8) - .background(.quaternary) - .clipShape(RoundedRectangle(cornerRadius: 8)) + SearchFieldView( + placeholder: "Search shortcuts...", + text: $searchText + ) .padding(.horizontal, 20) .padding(.top, 16) .padding(.bottom, 8) diff --git a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift index 27e634c41..3ccc08e2a 100644 --- a/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift +++ b/TablePro/Views/Toolbar/ConnectionSwitcherPopover.swift @@ -15,7 +15,6 @@ struct ConnectionSwitcherPopover: View { @State private var savedConnections: [DatabaseConnection] = [] @State private var isConnecting: UUID? @State private var selectedIndex: Int = 0 - @State private var keyMonitor: Any? @Environment(\.openWindow) private var openWindow @@ -157,61 +156,30 @@ struct ConnectionSwitcherPopover: View { selectedIndex = idx } } - installKeyMonitor() } - .onDisappear { - removeKeyMonitor() - } - } - - // MARK: - Keyboard Navigation - - private func installKeyMonitor() { - keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + .onExitCommand { onDismiss?() } + .onKeyPress(.return) { let items = allItems - switch event.keyCode { - case KeyCode.upArrow.rawValue: - if selectedIndex > 0 { - selectedIndex -= 1 - } - return nil - case KeyCode.downArrow.rawValue: - if selectedIndex < items.count - 1 { - selectedIndex += 1 - } - return nil - case KeyCode.return.rawValue: - guard selectedIndex >= 0, selectedIndex < items.count else { return event } - switch items[selectedIndex] { - case .session(let session): - switchToSession(session.id) - case .saved(let connection): - connectToSaved(connection) - } - return nil - case KeyCode.escape.rawValue: - onDismiss?() - return nil - case KeyCode.j.rawValue where event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.control): - if selectedIndex < items.count - 1 { - selectedIndex += 1 - } - return nil - case KeyCode.k.rawValue where event.modifierFlags.contains(.control): - if selectedIndex > 0 { - selectedIndex -= 1 - } - return nil - default: - return event + guard selectedIndex >= 0, selectedIndex < items.count else { return .ignored } + switch items[selectedIndex] { + case .session(let session): switchToSession(session.id) + case .saved(let connection): connectToSaved(connection) } + return .handled } - } - - private func removeKeyMonitor() { - if let monitor = keyMonitor { - NSEvent.removeMonitor(monitor) - keyMonitor = nil + .onKeyPress(.upArrow) { + moveSelection(by: -1) + } + .onKeyPress(.downArrow) { + moveSelection(by: 1) + } + .onKeyPress(characters: .init(charactersIn: "j"), phases: [.down, .repeat]) { keyPress in + guard keyPress.modifiers.contains(.control) else { return .ignored } + return moveSelection(by: 1) + } + .onKeyPress(characters: .init(charactersIn: "k"), phases: [.down, .repeat]) { keyPress in + guard keyPress.modifiers.contains(.control) else { return .ignored } + return moveSelection(by: -1) } } @@ -222,6 +190,13 @@ struct ConnectionSwitcherPopover: View { case saved(DatabaseConnection) } + private func moveSelection(by offset: Int) -> KeyPress.Result { + let newIndex = selectedIndex + offset + guard newIndex >= 0, newIndex < allItems.count else { return .handled } + selectedIndex = newIndex + return .handled + } + // MARK: - Subviews private func connectionRow(