diff --git a/apple/TablePro/Core/DataGrid/Binding+CommittingGridEdit.swift b/apple/TablePro/Core/DataGrid/Binding+CommittingGridEdit.swift new file mode 100644 index 0000000000..e67f7df75d --- /dev/null +++ b/apple/TablePro/Core/DataGrid/Binding+CommittingGridEdit.swift @@ -0,0 +1,23 @@ +// +// Binding+CommittingGridEdit.swift +// TablePro +// + +import SwiftUI + +extension Binding { + /// For a tab selection whose panes share one grid model. The open cell edit commits against the pane + /// being left before the new tab is published, because the incoming pane's grid can mount before + /// the outgoing one is dismantled, and an edit committed at that teardown would land on the new + /// pane's rows, which reuse the same row and column ids. + @MainActor + func committingEdit(of model: DataGridModel) -> Binding { + Binding( + get: { wrappedValue }, + set: { newValue in + model.commitEditing() + wrappedValue = newValue + } + ) + } +} diff --git a/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift b/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift index 1e9efd2d10..714e89c20f 100644 --- a/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift +++ b/apple/TablePro/Core/Services/Infrastructure/MainWindowToolbar.swift @@ -23,7 +23,9 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { /// record on the next launch. A user who customized keeps their arrangement and still receives /// new items where they belong; a bump would throw that away along with their display mode. /// The record is six keys and `isHidden` is not among them, since it is runtime state that is - /// never persisted, so a context-sensitive item set is no reason to bump either. + /// never persisted, so a context-sensitive item set is no reason to bump either. A reordered + /// default list needs no bump: a toolbar nobody customized stores no item list and takes the + /// new order, and a customized one keeps the user's (`scripts/probes/check-toolbar-autosave-reorder.sh`). /// /// Bump only for a change that diff cannot express, and say which. Neither fact was measured on /// macOS 13 or 14. @@ -411,8 +413,14 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { /// Eight controls, and the zones are the panes. AppKit's own sidebar toggle stands alone ahead of /// `.sidebarTrackingSeparator`, so it lays out in the sidebar's titlebar strip and follows the - /// divider; the connection and its container are centred; Refresh, the commit verb, the Actions - /// pull-down and Safe Mode are the content run; the trailing-pane toggle closes the row. + /// divider; the connection and its container are centred; Refresh, the Actions pull-down, Safe + /// Mode and the commit verb are the content run; the trailing-pane toggle closes the row. + /// + /// The commit verb comes last because the HIG gives a critical action like Save "dedicated, + /// familiar, and visually distinct sections" and puts the primary action on the trailing side. + /// Measured on macOS 27, AppKit already gives it a glass section of its own there: Safe Mode's + /// menu indicator ends the shared capsule before it, so no `.space` is needed, and one drew + /// pixel-identical. /// /// A tracking separator divides the toolbar into pane-aligned sections; it does not align the /// items inside one. Everything after `.inspectorTrackingSeparator` therefore lays out from @@ -444,9 +452,9 @@ internal final class MainWindowToolbar: NSObject, NSToolbarDelegate { database, .flexibleSpace, refresh, - saveChanges, actions, safeMode, + saveChanges, ] if #available(macOS 14.0, *) { items.append(.inspectorTrackingSeparator) diff --git a/apple/TablePro/Core/Services/Infrastructure/Toolbar/ToolbarSymbols.swift b/apple/TablePro/Core/Services/Infrastructure/Toolbar/ToolbarSymbols.swift index f12e17e0cb..648ce75104 100644 --- a/apple/TablePro/Core/Services/Infrastructure/Toolbar/ToolbarSymbols.swift +++ b/apple/TablePro/Core/Services/Infrastructure/Toolbar/ToolbarSymbols.swift @@ -5,17 +5,17 @@ import Foundation -/// The glyphs a window toolbar draws for the actions every toolbar shares. +/// The glyphs a toolbar draws for the actions every toolbar shares, in both apps. /// -/// From macOS 26 a toolbar item sits in its own glass container, and the HIG asks for symbols -/// without borders there: the container is the border, so a circled glyph draws two. Earlier -/// releases give an item no container, and the circled form is the one their own toolbars use. -/// Each answer is a function of that one fact, so both forms can be tested on either release. -internal enum ToolbarSymbols { +/// From macOS 26 and iOS 26 a toolbar item sits in its own glass container, and the HIG asks for +/// symbols without borders there: the container is the border, so a circled glyph draws two. +/// Earlier releases give an item no container, and the circled form is the one their own toolbars +/// use. Each answer is a function of that one fact, so both forms can be tested on either release. +nonisolated internal enum ToolbarSymbols { internal static let commit = "checkmark" internal static var itemsHaveContainer: Bool { - if #available(macOS 26.0, *) { + if #available(macOS 26.0, iOS 26.0, *) { return true } return false @@ -42,6 +42,17 @@ internal enum ToolbarSymbols { return itemsHaveContainer ? "line.3.horizontal.decrease" : "line.3.horizontal.decrease.circle" } + /// The filled disc is state, a sort in effect, as it is for the filter. + internal static func sort( + isActive: Bool = false, + itemsHaveContainer: Bool = ToolbarSymbols.itemsHaveContainer + ) -> String { + if isActive { + return "arrow.up.arrow.down.circle.fill" + } + return itemsHaveContainer ? "arrow.up.arrow.down" : "arrow.up.arrow.down.circle" + } + internal static func disconnect(itemsHaveContainer: Bool = ToolbarSymbols.itemsHaveContainer) -> String { itemsHaveContainer ? "xmark" : "xmark.circle" } diff --git a/apple/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift b/apple/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift index f334fa115a..f417403b01 100644 --- a/apple/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift +++ b/apple/TablePro/Views/AIChat/AIChatWalkthroughBlockView.swift @@ -85,8 +85,8 @@ struct AIChatWalkthroughBlockView: View { Spacer() if walkthrough.hasDiff { Picker(String(localized: "Diff layout"), selection: diffStyleBinding) { - Text("Unified").tag(SqlWalkthroughDiffStyle.unified) Text("Split").tag(SqlWalkthroughDiffStyle.split) + Text("Unified").tag(SqlWalkthroughDiffStyle.unified) } .pickerStyle(.segmented) .labelsHidden() diff --git a/apple/TablePro/Views/Compare/CompareDetailView.swift b/apple/TablePro/Views/Compare/CompareDetailView.swift index 2357e9fb06..4b8d88cb90 100644 --- a/apple/TablePro/Views/Compare/CompareDetailView.swift +++ b/apple/TablePro/Views/Compare/CompareDetailView.swift @@ -29,16 +29,15 @@ internal struct CompareDetailView: View { } private var paneSelector: some View { - Picker(String(localized: "Detail"), selection: $session.detailPane) { + PaneTabBar( + title: String(localized: "Detail"), + selection: $session.detailPane, + identifier: "compare-detail-tabs" + ) { ForEach(CompareDetailPane.allCases, id: \.self) { pane in Text(pane.title).tag(pane) } } - .pickerStyle(.segmented) - .labelsHidden() - .fixedSize() - .padding(.vertical, 8) - .frame(maxWidth: .infinity) } @ViewBuilder diff --git a/apple/TablePro/Views/Compare/CompareSyncWindowController.swift b/apple/TablePro/Views/Compare/CompareSyncWindowController.swift index 26e514086e..bd19dc5070 100644 --- a/apple/TablePro/Views/Compare/CompareSyncWindowController.swift +++ b/apple/TablePro/Views/Compare/CompareSyncWindowController.swift @@ -153,7 +153,11 @@ internal final class CompareSyncWindowController: NSWindowController, private func installToolbar(on window: NSWindow) { let toolbar = NSToolbar(identifier: "com.TablePro.CompareSyncToolbar") toolbar.delegate = self - toolbar.displayMode = .iconAndLabel + /// Icon only, the default every other TablePro window and the system apps ship. The source + /// and target items still read as words, since an item's title draws in this mode, and a + /// user who wants labels sets Icon and Text from Customize Toolbar, which is autosaved and + /// restored over this default. + toolbar.displayMode = .iconOnly toolbar.allowsUserCustomization = true toolbar.autosavesConfiguration = true window.toolbar = toolbar diff --git a/apple/TablePro/Views/Components/DiagramZoomToolbar.swift b/apple/TablePro/Views/Components/DiagramZoomToolbar.swift index 1c74411f82..bbbe412086 100644 --- a/apple/TablePro/Views/Components/DiagramZoomToolbar.swift +++ b/apple/TablePro/Views/Components/DiagramZoomToolbar.swift @@ -59,11 +59,26 @@ struct DiagramZoomToolbar: View { .buttonStyle(.borderless) .padding(.horizontal, 10) .padding(.vertical, 6) - .themeMaterial(.toolbar, .thinMaterial, in: Capsule()) - .overlay(Capsule().strokeBorder(.quaternary, lineWidth: 0.5)) + .modifier(DiagramZoomSurface()) } private var zoomPercentage: String { "\(Int((viewport.magnification * 100).rounded()))%" } } + +/// Controls floating over content take Liquid Glass from macOS 26, as the Open Quickly panel and +/// the chat's jump button already do. Glass draws its own edge and adapts to Reduce Transparency +/// and Increase Contrast itself, so the hairline and the solid fallback belong to the material +/// only. +private struct DiagramZoomSurface: ViewModifier { + func body(content: Content) -> some View { + if #available(macOS 26.0, *) { + content.glassEffect(.regular, in: Capsule()) + } else { + content + .themeMaterial(.toolbar, .thinMaterial, in: Capsule()) + .overlay(Capsule().strokeBorder(.quaternary, lineWidth: 0.5)) + } + } +} diff --git a/apple/TablePro/Views/Components/ViewSwitcherStyle.swift b/apple/TablePro/Views/Components/ViewSwitcherStyle.swift new file mode 100644 index 0000000000..fc1aa5cc8f --- /dev/null +++ b/apple/TablePro/Views/Components/ViewSwitcherStyle.swift @@ -0,0 +1,65 @@ +// +// ViewSwitcherStyle.swift +// TablePro +// + +import SwiftUI + +/// Measured on macOS 27, small `.tabs` draws a flat track with a raised neutral thumb, the look Apple +/// gives tab navigation, where small `.segmented` fills the thumb with the accent and reads as a value. +/// +/// To accessibility it is then a tab group of tab buttons rather than a radio group of segments. +private struct ViewSwitcherStyle: ViewModifier { + let controlSize: ControlSize + + func body(content: Content) -> some View { + switcher(content) + .controlSize(controlSize) + } + + @ViewBuilder + private func switcher(_ content: Content) -> some View { + #if SDK_MACOS_27 + if #available(macOS 27.0, *) { + content.pickerStyle(.tabs) + } else { + content.pickerStyle(.segmented) + } + #else + content.pickerStyle(.segmented) + #endif + } +} + +internal extension View { + /// For a picker that switches what a pane shows: small in a pane bar, status bar, viewer strip or + /// inspector, regular in a `PaneTabBar`. A picker that sets a value stays `.segmented`. + func viewSwitcherStyle(_ controlSize: ControlSize = .small) -> some View { + modifier(ViewSwitcherStyle(controlSize: controlSize)) + } +} + +/// The tab row a main-window pane switches its views with, centred over content that runs edge to +/// edge. A bordered tab view boxes and insets the grids these panes hold; the tabs role is the +/// control macOS 27 gives tab navigation, and before it the segmented control is. +internal struct PaneTabBar: View { + let title: String + @Binding var selection: Selection + let identifier: String + @ViewBuilder let tabs: () -> Tabs + + /// Spacers rather than a fixed size, so a pane narrower than the tabs compresses them instead of + /// pushing the outer ones past its edges. + var body: some View { + HStack(spacing: 0) { + Spacer(minLength: 0) + Picker(title, selection: $selection, content: tabs) + .labelsHidden() + .viewSwitcherStyle(.regular) + .accessibilityIdentifier(identifier) + Spacer(minLength: 0) + } + .padding(.horizontal) + .padding(.vertical, 8) + } +} diff --git a/apple/TablePro/Views/DataFiles/DataFileContentView.swift b/apple/TablePro/Views/DataFiles/DataFileContentView.swift index fe2a2dd979..e662a59e91 100644 --- a/apple/TablePro/Views/DataFiles/DataFileContentView.swift +++ b/apple/TablePro/Views/DataFiles/DataFileContentView.swift @@ -99,14 +99,15 @@ struct DataFileContentView: View { } struct DataFileSheetTabs: View { - static let segmentedSheetLimit = 8 + /// The HIG's limit for tabs, past which it asks for a pop-up menu instead. + static let segmentedSheetLimit = 6 @ObservedObject var controller: DataFileController var body: some View { HStack { if controller.sheets.count <= Self.segmentedSheetLimit { - picker.pickerStyle(.segmented) + picker.viewSwitcherStyle() } else { picker.pickerStyle(.menu) } @@ -130,5 +131,6 @@ struct DataFileSheetTabs: View { .labelsHidden() .fixedSize() .accessibilityLabel(String(localized: "Sheet")) + .accessibilityIdentifier("data-file-sheet-picker") } } diff --git a/apple/TablePro/Views/DataFiles/DataFileWindowController.swift b/apple/TablePro/Views/DataFiles/DataFileWindowController.swift index d558fda84b..945b1c42ef 100644 --- a/apple/TablePro/Views/DataFiles/DataFileWindowController.swift +++ b/apple/TablePro/Views/DataFiles/DataFileWindowController.swift @@ -12,7 +12,15 @@ extension NSToolbarItem.Identifier { static let dataFileColumns = NSToolbarItem.Identifier("com.TablePro.dataFile.columns") static let dataFileFilters = NSToolbarItem.Identifier("com.TablePro.dataFile.filters") static let dataFileSearch = NSToolbarItem.Identifier("com.TablePro.dataFile.search") - static let dataFileInspector = NSToolbarItem.Identifier("com.TablePro.dataFile.inspector") + /// AppKit's own inspector toggle from macOS 14, which draws the system glyph and sends + /// `toggleInspector(_:)` down the responder chain to the split controller. macOS 13 has no such + /// item, so it keeps an app-owned one. + static let dataFileInspector: NSToolbarItem.Identifier = { + if #available(macOS 14.0, *) { + return .toggleInspector + } + return NSToolbarItem.Identifier("com.TablePro.dataFile.inspector") + }() } @MainActor @@ -110,6 +118,7 @@ final class DataFileWindowController: NSWindowController, NSWindowDelegate, NSTo return makeItem(itemIdentifier, label: String(localized: "Filters"), symbol: ToolbarSymbols.filter(), action: #selector(DataFileSplitViewController.toggleFilterBar(_:))) case .dataFileInspector: + guard #unavailable(macOS 14.0) else { return nil } return makeItem(itemIdentifier, label: String(localized: "Inspector"), symbol: "sidebar.trailing", action: #selector(DataFileSplitViewController.toggleInspector(_:))) case .dataFileColumns: @@ -132,9 +141,18 @@ final class DataFileWindowController: NSWindowController, NSWindowDelegate, NSTo } } + /// The inspector toggle follows the order the main window uses: after the tracking separator + /// and a flexible space, so it sits over the inspector and stays at the trailing edge whether + /// the pane is open or not. func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { - [.dataFileAddRow, .dataFileDeleteRows, .dataFileColumns, .flexibleSpace, .dataFileFilters, - .dataFileSearch, .dataFileInspector] + var items: [NSToolbarItem.Identifier] = [ + .dataFileAddRow, .dataFileDeleteRows, .dataFileColumns, .flexibleSpace, .dataFileFilters, .dataFileSearch, + ] + if #available(macOS 14.0, *) { + items.append(contentsOf: [.inspectorTrackingSeparator, .flexibleSpace]) + } + items.append(.dataFileInspector) + return items } func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { diff --git a/apple/TablePro/Views/Integrations/IntegrationsActivityLogPane.swift b/apple/TablePro/Views/Integrations/IntegrationsActivityLogPane.swift index d23b875f30..099d929d3d 100644 --- a/apple/TablePro/Views/Integrations/IntegrationsActivityLogPane.swift +++ b/apple/TablePro/Views/Integrations/IntegrationsActivityLogPane.swift @@ -154,11 +154,13 @@ struct IntegrationsActivityLogPane: View { .disabled(isLoading) } + /// `sidebar.trailing` is the glyph AppKit's own inspector toggle draws, and unlike + /// `sidebar.right` it mirrors in a right-to-left layout. private var inspectorToggle: some View { Button { showInspector.toggle() } label: { - Label(String(localized: "Details"), systemImage: "sidebar.right") + Label(String(localized: "Details"), systemImage: "sidebar.trailing") } .help(String(localized: "Show details")) } diff --git a/apple/TablePro/Views/ObjectSource/UserType/UserTypeTabView.swift b/apple/TablePro/Views/ObjectSource/UserType/UserTypeTabView.swift index b3c5e3914e..c4a73e2b75 100644 --- a/apple/TablePro/Views/ObjectSource/UserType/UserTypeTabView.swift +++ b/apple/TablePro/Views/ObjectSource/UserType/UserTypeTabView.swift @@ -186,20 +186,15 @@ struct UserTypeTabView: View { private func editor(_ projection: UserTypeDraft.Projection) -> some View { VStack(spacing: 0) { - HStack { - Spacer() - Picker(String(localized: "Section"), selection: $model.section) { - ForEach(sections, id: \.self) { section in - Text(title(for: section)).tag(section) - } + PaneTabBar( + title: String(localized: "Section"), + selection: $model.section, + identifier: "user-type-section-picker" + ) { + ForEach(sections, id: \.self) { section in + Text(title(for: section)).tag(section) } - .pickerStyle(.segmented) - .labelsHidden() - .fixedSize() - .accessibilityIdentifier("user-type-section-picker") - Spacer() } - .padding(.vertical, 8) Divider() Group { switch model.section { diff --git a/apple/TablePro/Views/QueryPlan/QueryPlanResultView.swift b/apple/TablePro/Views/QueryPlan/QueryPlanResultView.swift index 4c47fa4b84..a84f4c1fba 100644 --- a/apple/TablePro/Views/QueryPlan/QueryPlanResultView.swift +++ b/apple/TablePro/Views/QueryPlan/QueryPlanResultView.swift @@ -187,8 +187,7 @@ struct QueryPlanResultView: View { Text(mode.title).tag(mode) } } - .pickerStyle(.segmented) - .controlSize(.small) + .viewSwitcherStyle() .fixedSize() .labelsHidden() .accessibilityIdentifier("query-plan-mode-picker") diff --git a/apple/TablePro/Views/Results/BinaryValueViewerView.swift b/apple/TablePro/Views/Results/BinaryValueViewerView.swift index 90c7343a20..43d6f59ed2 100644 --- a/apple/TablePro/Views/Results/BinaryValueViewerView.swift +++ b/apple/TablePro/Views/Results/BinaryValueViewerView.swift @@ -57,9 +57,10 @@ internal struct BinaryValueViewerView: View { Text(String(localized: "Text")).tag(BinaryValueViewMode.text) Text(String(localized: "Hex")).tag(BinaryValueViewMode.hex) } - .pickerStyle(.segmented) + .viewSwitcherStyle() .labelsHidden() .fixedSize() + .accessibilityIdentifier("binary-viewer-mode-picker") } else { Text(format.displayName) .font(.headline) diff --git a/apple/TablePro/Views/Results/CellImageViewer.swift b/apple/TablePro/Views/Results/CellImageViewer.swift index 9e3212b212..a6e5fc24f0 100644 --- a/apple/TablePro/Views/Results/CellImageViewer.swift +++ b/apple/TablePro/Views/Results/CellImageViewer.swift @@ -61,9 +61,10 @@ internal struct CellImageViewer: View { Text(String(localized: "Image")).tag(CellImageViewMode.image) Text(sourceKind.label).tag(CellImageViewMode.source) } - .pickerStyle(.segmented) + .viewSwitcherStyle() .labelsHidden() .fixedSize() + .accessibilityIdentifier("image-viewer-mode-picker") Spacer() diff --git a/apple/TablePro/Views/Results/JSONViewerView.swift b/apple/TablePro/Views/Results/JSONViewerView.swift index e127cdb2d4..ea6bdf22c8 100644 --- a/apple/TablePro/Views/Results/JSONViewerView.swift +++ b/apple/TablePro/Views/Results/JSONViewerView.swift @@ -122,9 +122,10 @@ internal struct JSONViewerView: View { Text("Text").tag(JSONViewMode.text) Text("Tree").tag(JSONViewMode.tree) } - .pickerStyle(.segmented) + .viewSwitcherStyle() .labelsHidden() .fixedSize() + .accessibilityIdentifier("json-viewer-mode-picker") Spacer() if let onPopOut { Button { onPopOut(displayText) } label: { diff --git a/apple/TablePro/Views/Results/PhpViewerView.swift b/apple/TablePro/Views/Results/PhpViewerView.swift index 937774f15d..df7030b9fb 100644 --- a/apple/TablePro/Views/Results/PhpViewerView.swift +++ b/apple/TablePro/Views/Results/PhpViewerView.swift @@ -75,9 +75,10 @@ internal struct PhpViewerView: View { Text(String(localized: "Tree")).tag(PhpViewMode.tree) Text(String(localized: "Raw")).tag(PhpViewMode.raw) } - .pickerStyle(.segmented) + .viewSwitcherStyle() .labelsHidden() .fixedSize() + .accessibilityIdentifier("php-viewer-mode-picker") Spacer() if let onPopOut { Button { onPopOut(rawValue) } label: { diff --git a/apple/TablePro/Views/Results/ResultStatusBar.swift b/apple/TablePro/Views/Results/ResultStatusBar.swift index 35605095ad..07980fcdec 100644 --- a/apple/TablePro/Views/Results/ResultStatusBar.swift +++ b/apple/TablePro/Views/Results/ResultStatusBar.swift @@ -146,7 +146,7 @@ struct ResultStatusBar: View { if presentation.modeSwitcherIsSegmented { picker - .pickerStyle(.segmented) + .viewSwitcherStyle() .fixedSize() } else { picker diff --git a/apple/TablePro/Views/Results/ResultsJsonView.swift b/apple/TablePro/Views/Results/ResultsJsonView.swift index 0c6b96b9d6..ef6606d4a9 100644 --- a/apple/TablePro/Views/Results/ResultsJsonView.swift +++ b/apple/TablePro/Views/Results/ResultsJsonView.swift @@ -123,9 +123,10 @@ internal struct ResultsJsonView: View { Text("Text").tag(JSONViewMode.text) Text("Tree").tag(JSONViewMode.tree) } - .pickerStyle(.segmented) + .viewSwitcherStyle() .labelsHidden() .fixedSize() + .accessibilityIdentifier("results-json-mode-picker") Spacer() diff --git a/apple/TablePro/Views/RowInspector/FieldEditors/GeometryFieldView.swift b/apple/TablePro/Views/RowInspector/FieldEditors/GeometryFieldView.swift index c59b5b409c..ec283a95de 100644 --- a/apple/TablePro/Views/RowInspector/FieldEditors/GeometryFieldView.swift +++ b/apple/TablePro/Views/RowInspector/FieldEditors/GeometryFieldView.swift @@ -83,9 +83,8 @@ internal struct GeometryFieldView: View { Text(String(localized: "Text")).tag(Mode.text) Text(String(localized: "Map")).tag(Mode.map) } - .pickerStyle(.segmented) + .viewSwitcherStyle() .labelsHidden() - .controlSize(.small) .fixedSize() .accessibilityIdentifier("inspector-geometry-mode") diff --git a/apple/TablePro/Views/RowInspector/TrailingPaneHeaderView.swift b/apple/TablePro/Views/RowInspector/TrailingPaneHeaderView.swift index 0f544c4ba3..9fe7349c6e 100644 --- a/apple/TablePro/Views/RowInspector/TrailingPaneHeaderView.swift +++ b/apple/TablePro/Views/RowInspector/TrailingPaneHeaderView.swift @@ -146,10 +146,9 @@ private struct TrailingPaneSurfacePicker: View { .tag(segment) } } - .pickerStyle(.segmented) + .viewSwitcherStyle() .labelsHidden() .fixedSize() - .controlSize(.small) .accessibilityIdentifier("trailing-pane-surface") } diff --git a/apple/TablePro/Views/Sidebar/SidebarScopeControl.swift b/apple/TablePro/Views/Sidebar/SidebarScopeControl.swift index 7d2a4fe3b5..5a42170410 100644 --- a/apple/TablePro/Views/Sidebar/SidebarScopeControl.swift +++ b/apple/TablePro/Views/Sidebar/SidebarScopeControl.swift @@ -20,6 +20,12 @@ import AppKit /// The words fit in every language the app ships. Measured at the sidebar's 280pt minimum, which /// leaves the row 260pt inside its insets, the widest is Turkish at 238pt, English needs 158pt and /// Simplified Chinese 100pt. +/// +/// On macOS 27 it takes the tabs role AppKit gives "tab-based navigation and content selection", +/// at small size. Measured there, a regular control in a sidebar draws a glass capsule whatever +/// its style or role; small with the tabs role draws a flat track with a neutral raised thumb. +/// Small without the role would fill the thumb with the accent color, which marks a value choice, +/// so earlier releases keep the regular control. @MainActor internal final class SidebarScopeControl: NSSegmentedControl { internal static let tabs: [SidebarTab] = [.tables, .favorites] @@ -30,6 +36,12 @@ internal final class SidebarScopeControl: NSSegmentedControl { trackingMode = .selectOne segmentDistribution = .fillEqually controlSize = .regular + #if SDK_MACOS_27 + if #available(macOS 27.0, *) { + role = .tabs + controlSize = .small + } + #endif for (index, tab) in Self.tabs.enumerated() { setLabel(Self.title(for: tab), forSegment: index) } diff --git a/apple/TablePro/Views/Structure/CreateTableFormEditor.swift b/apple/TablePro/Views/Structure/CreateTableFormEditor.swift index 604d8d0f48..0c44badea8 100644 --- a/apple/TablePro/Views/Structure/CreateTableFormEditor.swift +++ b/apple/TablePro/Views/Structure/CreateTableFormEditor.swift @@ -32,6 +32,33 @@ internal struct CreateTableFormEditor: View { let issues = form.issues(tableName: draft.tableName) let messages = [form.submissionError?.message].compactMap { $0 } + issues.map(\.qualifiedMessage) VStack(spacing: 0) { + PaneTabBar( + title: String(localized: "View"), + selection: $pane, + identifier: "create-table-form-tabs" + ) { + ForEach(CreateTableFormPane.allCases, id: \.self) { tab in + Text(tab.displayName).tag(tab) + } + } + + Divider() + + Group { + switch pane { + case .form: + CreateTableFormView(draft: draft) + case .preview: + CreateTableFormPreview( + preview: form.preview(tableName: draft.tableName, generate: generateStatements), + databaseType: databaseType + ) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + + Divider() + HStack(spacing: 8) { if let first = messages.first { Label(first, systemImage: "exclamationmark.triangle.fill") @@ -45,17 +72,6 @@ internal struct CreateTableFormEditor: View { Spacer(minLength: 12) - Picker(String(localized: "View"), selection: $pane) { - ForEach(CreateTableFormPane.allCases, id: \.self) { pane in - Text(pane.displayName).tag(pane) - } - } - .pickerStyle(.segmented) - .labelsHidden() - .fixedSize() - - Spacer(minLength: 12) - Button( isCreating ? String(localized: "Creating…") : String(localized: "Create Table"), action: onCreate @@ -66,19 +82,8 @@ internal struct CreateTableFormEditor: View { .keyboardShortcut(.return, modifiers: .command) .accessibilityIdentifier("create-table-commit") } - .padding() - - Divider() - - switch pane { - case .form: - CreateTableFormView(draft: draft) - case .preview: - CreateTableFormPreview( - preview: form.preview(tableName: draft.tableName, generate: generateStatements), - databaseType: databaseType - ) - } + .padding(.horizontal) + .padding(.vertical, 8) } } } diff --git a/apple/TablePro/Views/Structure/CreateTableView.swift b/apple/TablePro/Views/Structure/CreateTableView.swift index 24f6604657..5c890d61a1 100644 --- a/apple/TablePro/Views/Structure/CreateTableView.swift +++ b/apple/TablePro/Views/Structure/CreateTableView.swift @@ -113,9 +113,12 @@ struct CreateTableView: View { onCreate: { createTable() } ) } else if draft.hasResolvedForm { - toolbar + tabBar Divider() tabContent + .frame(maxWidth: .infinity, maxHeight: .infinity) + Divider() + commitBar } else { Color.clear } @@ -207,7 +210,7 @@ struct CreateTableView: View { return support.commentableTypes.contains(.table) } - // MARK: - Toolbar + // MARK: - Tabs private var availableTabs: [CreateTableTab] { var tabs = CreateTableTab.allCases @@ -217,44 +220,39 @@ struct CreateTableView: View { return tabs } - private var isGridTab: Bool { - selectedTab != .sqlPreview + private var tabBar: some View { + PaneTabBar( + title: String(localized: "Structure"), + selection: $selectedTab.committingEdit(of: gridModel), + identifier: "create-table-tabs" + ) { + ForEach(availableTabs, id: \.self) { tab in + Text(tab.displayName).tag(tab) + } + } } - private var toolbar: some View { - let issues = draft.composed?.issues ?? draft.plan(for: connection.type).issues + // One grid serves the three grid tabs: the adapter repoints it as the selection moves. + @ViewBuilder + private var tabContent: some View { + switch selectedTab { + case .columns, .indexes, .foreignKeys: + structureGridPane + case .sqlPreview: + sqlPreviewView + } + } - return HStack(spacing: 8) { - Button(action: { gridAdapter.addRow() }) { - Image(systemName: "plus") - .frame(width: 24, height: 24) - } - .help(String(localized: "Add Row")) - .accessibilityLabel(String(localized: "Add Row")) - .disabled(!isGridTab) + // MARK: - Commit Bar - Button(action: { gridAdapter.deleteRows(gridModel.selectedRowIDs) }) { - Image(systemName: "minus") - .frame(width: 24, height: 24) - } - .help(String(localized: "Delete Selected")) - .accessibilityLabel(String(localized: "Delete Selected")) - .disabled(!isGridTab || gridModel.selection.isEmpty) + private var commitBar: some View { + let issues = draft.composed?.issues ?? draft.plan(for: connection.type).issues + return HStack(spacing: 8) { issueMessage(issues) Spacer(minLength: 12) - Picker(String(localized: "Structure"), selection: $selectedTab) { - ForEach(availableTabs, id: \.self) { tab in - Text(tab.displayName).tag(tab) - } - } - .pickerStyle(.segmented) - .labelsHidden() - - Spacer(minLength: 12) - Button(isCreating ? String(localized: "Creating…") : String(localized: "Create Table")) { createTable() } @@ -264,7 +262,8 @@ struct CreateTableView: View { .keyboardShortcut(.return, modifiers: .command) .accessibilityIdentifier("create-table-commit") } - .padding() + .padding(.horizontal) + .padding(.vertical, 8) } @ViewBuilder @@ -284,18 +283,6 @@ struct CreateTableView: View { issue.tab == structureTab ? issue.message : issue.qualifiedMessage } - // MARK: - Tab Content - - @ViewBuilder - private var tabContent: some View { - switch selectedTab { - case .columns, .indexes, .foreignKeys: - structureGrid - case .sqlPreview: - sqlPreviewView - } - } - // MARK: - Structure Grid private var structureTab: StructureTab { @@ -307,8 +294,45 @@ struct CreateTableView: View { } } - private var structureGrid: some View { - DataGridView(model: gridModel) + private var structureGridPane: some View { + VStack(spacing: 0) { + DataGridView(model: gridModel) + Divider() + rowButtons(identifier: rowButtonsIdentifier) + } + } + + private var rowButtonsIdentifier: String { + switch selectedTab { + case .columns, .sqlPreview: return "create-table-columns" + case .indexes: return "create-table-indexes" + case .foreignKeys: return "create-table-foreign-keys" + } + } + + // Add and Delete act on the shown tab's rows, since the adapter follows the selected tab. + private func rowButtons(identifier: String) -> some View { + HStack(spacing: 8) { + Button(action: { gridAdapter.addRow() }) { + Image(systemName: "plus") + .frame(width: 24, height: 24) + } + .help(String(localized: "Add Row")) + .accessibilityLabel(String(localized: "Add Row")) + .accessibilityIdentifier("\(identifier)-add") + + Button(action: { gridAdapter.deleteRows(gridModel.selectedRowIDs) }) { + Image(systemName: "minus") + .frame(width: 24, height: 24) + } + .help(String(localized: "Delete Selected")) + .accessibilityLabel(String(localized: "Delete Selected")) + .disabled(gridModel.selection.isEmpty) + .accessibilityIdentifier("\(identifier)-remove") + + Spacer() + } + .padding(8) } // MARK: - SQL Preview diff --git a/apple/TablePro/Views/Structure/TableStructureView.swift b/apple/TablePro/Views/Structure/TableStructureView.swift index c7f4083ba7..30afcfce05 100644 --- a/apple/TablePro/Views/Structure/TableStructureView.swift +++ b/apple/TablePro/Views/Structure/TableStructureView.swift @@ -148,7 +148,7 @@ struct TableStructureView: View { var body: some View { VStack(spacing: 0) { tableCommentBar - toolbar + tabBar Divider() contentArea .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -224,29 +224,23 @@ struct TableStructureView: View { } } - // MARK: - Toolbar + // MARK: - Tabs private var availableTabs: [StructureTab] { session.availableTabs } - private var toolbar: some View { - HStack { - Spacer() - - Picker("Structure", selection: $session.selectedTab) { - ForEach(availableTabs, id: \.self) { tab in - Text(tabLabel(for: tab)).tag(tab) - } + private var tabBar: some View { + PaneTabBar( + title: String(localized: "Structure"), + selection: $session.selectedTab.committingEdit(of: session.gridModel), + identifier: "structure-tab-picker" + ) { + ForEach(availableTabs, id: \.self) { tab in + Text(tabLabel(for: tab)).tag(tab) } - .pickerStyle(.segmented) - .labelsHidden() - .monospacedDigit() - .accessibilityIdentifier("structure-tab-picker") - - Spacer() } - .padding() + .monospacedDigit() .overlay(alignment: .trailing) { if structureChangeManager.isHeldForSave { HStack(spacing: 6) { diff --git a/apple/TablePro/Views/UsersRoles/PrincipalDetailPane.swift b/apple/TablePro/Views/UsersRoles/PrincipalDetailPane.swift index 7f19473eb0..aea26eaa01 100644 --- a/apple/TablePro/Views/UsersRoles/PrincipalDetailPane.swift +++ b/apple/TablePro/Views/UsersRoles/PrincipalDetailPane.swift @@ -43,8 +43,8 @@ struct PrincipalDetailPane: View { Text(segment.title).tag(segment) } } - .pickerStyle(.segmented) .labelsHidden() + .viewSwitcherStyle() .fixedSize() .accessibilityIdentifier("usersroles-detail-segment") } diff --git a/apple/TablePro/Views/UsersRoles/PrivilegeScopeOutlineCoordinator.swift b/apple/TablePro/Views/UsersRoles/PrivilegeScopeOutlineCoordinator.swift index b543a45e93..a26e3ef286 100644 --- a/apple/TablePro/Views/UsersRoles/PrivilegeScopeOutlineCoordinator.swift +++ b/apple/TablePro/Views/UsersRoles/PrivilegeScopeOutlineCoordinator.swift @@ -21,6 +21,7 @@ final class PrivilegeScopeOutlineCoordinator: NSObject, NSOutlineViewDataSource, var principal: PluginPrincipalRef? private var isRestoringExpansion = false + private var isRestoringSelection = false private var pendingExpansions: Set = [] private var nodeChanges: AnyCancellable? @@ -115,10 +116,16 @@ final class PrivilegeScopeOutlineCoordinator: NSObject, NSOutlineViewDataSource, } func restoreExpansion() { - guard viewModel.privilegeTree.mode == .hierarchy else { return } + guard viewModel.privilegeTree.mode == .hierarchy else { + restoreSelection() + return + } let saved = Set(viewModel.expansionStore.load()) - guard !saved.isEmpty else { return } + guard !saved.isEmpty else { + restoreSelection() + return + } Task { @MainActor in isRestoringExpansion = true @@ -137,13 +144,29 @@ final class PrivilegeScopeOutlineCoordinator: NSObject, NSOutlineViewDataSource, } frontier = next } + restoreSelection() } } + /// The outline is rebuilt each time Privileges is shown again, while the checklist keeps acting + /// on the scopes the model holds, so the rows for those scopes are selected again. A scope inside + /// a collapsed node has no row, and the model keeps it rather than shrinking to what is visible. + func restoreSelection() { + guard let outlineView, !viewModel.selectedScopes.isEmpty else { return } + let rows = IndexSet((0..(spacing: CGFloat? = 0, @ViewBuilder _ bar: () -> Bar) -> some View { - modifier(BottomSafeAreaBar(spacing: spacing, bar: bar())) - } -} - -private struct BottomSafeAreaBar: ViewModifier { - let spacing: CGFloat? - let bar: Bar - - func body(content: Content) -> some View { - if #available(iOS 26.0, *) { - content.safeAreaBar(edge: .bottom, spacing: spacing) { bar } - } else { - content.safeAreaInset(edge: .bottom, spacing: spacing) { - bar.background(.bar) - } - } - } -} diff --git a/apple/TableProMobile/TableProMobile/Views/Components/SafeAreaBar.swift b/apple/TableProMobile/TableProMobile/Views/Components/SafeAreaBar.swift new file mode 100644 index 0000000000..4173f1a675 --- /dev/null +++ b/apple/TableProMobile/TableProMobile/Views/Components/SafeAreaBar.swift @@ -0,0 +1,27 @@ +import SwiftUI + +extension View { + func bottomSafeAreaBar(spacing: CGFloat? = 0, @ViewBuilder _ bar: () -> Bar) -> some View { + modifier(SafeAreaBar(edge: .bottom, spacing: spacing, bar: bar())) + } + + func topSafeAreaBar(spacing: CGFloat? = 0, @ViewBuilder _ bar: () -> Bar) -> some View { + modifier(SafeAreaBar(edge: .top, spacing: spacing, bar: bar())) + } +} + +private struct SafeAreaBar: ViewModifier { + let edge: VerticalEdge + let spacing: CGFloat? + let bar: Bar + + func body(content: Content) -> some View { + if #available(iOS 26.0, *) { + content.safeAreaBar(edge: edge, spacing: spacing) { bar } + } else { + content.safeAreaInset(edge: edge, spacing: spacing) { + bar.background(.bar) + } + } + } +} diff --git a/apple/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/apple/TableProMobile/TableProMobile/Views/ConnectionListView.swift index 16b26b208b..68eb75f77f 100644 --- a/apple/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/apple/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -677,7 +677,7 @@ struct ConnectionListView: View { .disabled(!appState.connections.contains(where: \.participatesInSync)) } } label: { - Label("More", systemImage: "ellipsis.circle") + Label("More", systemImage: ToolbarSymbols.more()) } } diff --git a/apple/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/apple/TableProMobile/TableProMobile/Views/DataBrowserView.swift index 9325795562..b70ab4aa42 100644 --- a/apple/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/apple/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -468,7 +468,7 @@ struct DataBrowserView: View { } } } label: { - Label("More", systemImage: "ellipsis.circle") + Label("More", systemImage: ToolbarSymbols.more()) } } if canInsertRow { @@ -500,17 +500,13 @@ struct DataBrowserView: View { .pickerStyle(.inline) } } label: { - Label("Sort", systemImage: viewModel.sortState.isSorting - ? "arrow.up.arrow.down.circle.fill" - : "arrow.up.arrow.down.circle") + Label("Sort", systemImage: ToolbarSymbols.sort(isActive: viewModel.sortState.isSorting)) } .disabled(columns.isEmpty) } ToolbarItem(placement: .topBarTrailing) { Button { showFilterSheet = true } label: { - Label("Filter", systemImage: viewModel.hasActiveFilters - ? "line.3.horizontal.decrease.circle.fill" - : "line.3.horizontal.decrease.circle") + Label("Filter", systemImage: ToolbarSymbols.filter(isActive: viewModel.hasActiveFilters)) } .badge(viewModel.activeFilterCount) } diff --git a/apple/TableProMobile/TableProMobile/Views/StructureView.swift b/apple/TableProMobile/TableProMobile/Views/StructureView.swift index 7f875e3d95..8c02b240e0 100644 --- a/apple/TableProMobile/TableProMobile/Views/StructureView.swift +++ b/apple/TableProMobile/TableProMobile/Views/StructureView.swift @@ -53,7 +53,7 @@ struct StructureView: View { } } } - .safeAreaInset(edge: .top) { + .topSafeAreaBar(spacing: nil) { Picker("Section", selection: $selectedTab) { ForEach(Tab.allCases, id: \.self) { tab in Text(tab.displayName).tag(tab) @@ -62,7 +62,6 @@ struct StructureView: View { .pickerStyle(.segmented) .padding(.horizontal) .padding(.vertical, 8) - .background(.bar) } .navigationTitle(table.name) .navigationBarTitleDisplayMode(.inline) diff --git a/apple/TableProMobile/project.yml b/apple/TableProMobile/project.yml index 9558606aa5..6db30666db 100644 --- a/apple/TableProMobile/project.yml +++ b/apple/TableProMobile/project.yml @@ -67,6 +67,8 @@ targets: # through a socket. - ../Plugins/MySQLDriverPlugin/MySQLLocalSocket.swift - ../Plugins/Shared/PluginQueryTimeout.swift + # The toolbar glyph rule both apps follow on and before the 26 releases. + - ../TablePro/Core/Services/Infrastructure/Toolbar/ToolbarSymbols.swift # The ClickHouse plugin driver runs whole behind PluginBackedDriver, so every file of the plugin is compiled in. - path: ../Plugins/ClickHouseDriverPlugin excludes: diff --git a/apple/TableProTests/Core/DataGrid/BindingCommittingGridEditTests.swift b/apple/TableProTests/Core/DataGrid/BindingCommittingGridEditTests.swift new file mode 100644 index 0000000000..b178ae1dd5 --- /dev/null +++ b/apple/TableProTests/Core/DataGrid/BindingCommittingGridEditTests.swift @@ -0,0 +1,46 @@ +// +// BindingCommittingGridEditTests.swift +// TableProTests +// + +import SwiftUI +@testable import TablePro +import Testing + +@MainActor +struct BindingCommittingGridEditTests { + @MainActor + private final class Selection { + var tab = "columns" + } + + @Test("A tab change commits the open edit while the pane being left is still selected") + func commitsBeforePublishing() { + let model = DataGridModel(configuration: DataGridConfiguration()) + let grid = DataTabGridMountStub() + grid.isEditing = true + model.attach(grid) + let selection = Selection() + var tabAtCommit: String? + grid.onCommit = { tabAtCommit = selection.tab } + let binding = Binding(get: { selection.tab }, set: { selection.tab = $0 }).committingEdit(of: model) + + binding.wrappedValue = "indexes" + + #expect(tabAtCommit == "columns") + #expect(selection.tab == "indexes") + #expect(grid.commitCount == 1) + #expect(!grid.isEditing) + } + + @Test("With no grid mounted the tab still changes") + func changesWithoutAGrid() { + let model = DataGridModel(configuration: DataGridConfiguration()) + let selection = Selection() + let binding = Binding(get: { selection.tab }, set: { selection.tab = $0 }).committingEdit(of: model) + + binding.wrappedValue = "indexes" + + #expect(selection.tab == "indexes") + } +} diff --git a/apple/TableProTests/Core/Menu/ChromeSnapshot.json b/apple/TableProTests/Core/Menu/ChromeSnapshot.json index 9d709aff7a..538e3869b2 100644 --- a/apple/TableProTests/Core/Menu/ChromeSnapshot.json +++ b/apple/TableProTests/Core/Menu/ChromeSnapshot.json @@ -323,9 +323,9 @@ "com.TablePro.toolbar.database", "NSToolbarFlexibleSpaceItem", "com.TablePro.toolbar.refresh", - "com.TablePro.toolbar.saveChanges", "com.TablePro.toolbar.actions", "com.TablePro.toolbar.safeMode", + "com.TablePro.toolbar.saveChanges", "NSToolbarInspectorTrackingSeparatorItemIdentifier", "NSToolbarFlexibleSpaceItem", "NSToolbarToggleInspectorItem" @@ -338,9 +338,9 @@ "com.TablePro.toolbar.database", "NSToolbarFlexibleSpaceItem", "com.TablePro.toolbar.refresh", - "com.TablePro.toolbar.saveChanges", "com.TablePro.toolbar.actions", "com.TablePro.toolbar.safeMode", + "com.TablePro.toolbar.saveChanges", "NSToolbarInspectorTrackingSeparatorItemIdentifier", "NSToolbarFlexibleSpaceItem", "NSToolbarToggleInspectorItem", diff --git a/apple/TableProTests/Core/Services/Infrastructure/ToolbarSymbolsTests.swift b/apple/TableProTests/Core/Services/Infrastructure/ToolbarSymbolsTests.swift index 2083d57877..7308ac5d35 100644 --- a/apple/TableProTests/Core/Services/Infrastructure/ToolbarSymbolsTests.swift +++ b/apple/TableProTests/Core/Services/Infrastructure/ToolbarSymbolsTests.swift @@ -14,6 +14,7 @@ struct ToolbarSymbolsTests { func containedItemsDropTheCircle() { #expect(ToolbarSymbols.more(itemsHaveContainer: true) == "ellipsis") #expect(ToolbarSymbols.filter(isActive: false, itemsHaveContainer: true) == "line.3.horizontal.decrease") + #expect(ToolbarSymbols.sort(isActive: false, itemsHaveContainer: true) == "arrow.up.arrow.down") #expect(ToolbarSymbols.disconnect(itemsHaveContainer: true) == "xmark") } @@ -23,16 +24,21 @@ struct ToolbarSymbolsTests { #expect( ToolbarSymbols.filter(isActive: false, itemsHaveContainer: false) == "line.3.horizontal.decrease.circle" ) + #expect(ToolbarSymbols.sort(isActive: false, itemsHaveContainer: false) == "arrow.up.arrow.down.circle") #expect(ToolbarSymbols.disconnect(itemsHaveContainer: false) == "xmark.circle") } /// There is no filled form of the bare glyph, so the disc is the only way to say a filter is on. - @Test("An active filter is the filled disc on both", arguments: [true, false]) + @Test("An active filter or sort is the filled disc on both", arguments: [true, false]) func activeFilterIsFilled(itemsHaveContainer: Bool) { #expect( ToolbarSymbols.filter(isActive: true, itemsHaveContainer: itemsHaveContainer) == "line.3.horizontal.decrease.circle.fill" ) + #expect( + ToolbarSymbols.sort(isActive: true, itemsHaveContainer: itemsHaveContainer) + == "arrow.up.arrow.down.circle.fill" + ) } @Test("Only a More control without a container draws the menu indicator") @@ -56,10 +62,11 @@ struct ToolbarSymbolsTests { names.insert(ToolbarSymbols.disconnect(itemsHaveContainer: itemsHaveContainer)) for isActive in [true, false] { names.insert(ToolbarSymbols.filter(isActive: isActive, itemsHaveContainer: itemsHaveContainer)) + names.insert(ToolbarSymbols.sort(isActive: isActive, itemsHaveContainer: itemsHaveContainer)) } } - #expect(names.count == 8) + #expect(names.count == 11) for name in names { #expect( NSImage(systemSymbolName: name, accessibilityDescription: nil) != nil, diff --git a/apple/TableProTests/Views/Components/ViewSwitcherStyleTests.swift b/apple/TableProTests/Views/Components/ViewSwitcherStyleTests.swift new file mode 100644 index 0000000000..ea7582c5b3 --- /dev/null +++ b/apple/TableProTests/Views/Components/ViewSwitcherStyleTests.swift @@ -0,0 +1,76 @@ +import AppKit +import SwiftUI +import Testing + +@testable import TablePro + +@MainActor +struct ViewSwitcherStyleTests { + private enum Mode: Hashable { + case first + case second + } + + private struct Switcher: View { + @State private var mode = Mode.first + + var body: some View { + Picker(selection: $mode) { + Text(verbatim: "First").tag(Mode.first) + Text(verbatim: "Second").tag(Mode.second) + } label: { + Text(verbatim: "Mode") + } + .labelsHidden() + .fixedSize() + .viewSwitcherStyle() + } + } + + @Test("A view switcher is small, with the tabs role on macOS 27") + func smallWithTabsRoleOnMacOS27() throws { + let (host, control) = try hostedControl() + #expect(control.controlSize == .small) + #if SDK_MACOS_27 + if #available(macOS 27.0, *) { + #expect(control.role == .tabs) + } + #endif + withExtendedLifetime(host) {} + } + + /// Measured on macOS 27, the tabs role turns the radio group into a tab group, but each segment + /// is still a radio button named by its label, which is what a UI test clicks. + @Test("Each segment stays a radio button named by its label") + func segmentsStayNamedRadioButtons() throws { + let (host, control) = try hostedControl() + var labels: [String] = [] + func collect(_ element: Any, depth: Int) { + guard depth < 4, let object = element as? NSObject else { return } + if (object.value(forKey: "accessibilityRole") as? String) == NSAccessibility.Role.radioButton.rawValue, + let label = object.value(forKey: "accessibilityLabel") as? String { + labels.append(label) + } + for child in (object.value(forKey: "accessibilityChildren") as? [Any]) ?? [] { + collect(child, depth: depth + 1) + } + } + collect(control, depth: 0) + + #expect(labels == ["First", "Second"]) + withExtendedLifetime(host) {} + } + + private func hostedControl() throws -> (NSHostingView, NSSegmentedControl) { + let host = NSHostingView(rootView: Switcher()) + host.frame = NSRect(x: 0, y: 0, width: 300, height: 40) + host.layoutSubtreeIfNeeded() + let control = try #require(segmentedControls(in: host).first) + return (host, control) + } + + private func segmentedControls(in view: NSView) -> [NSSegmentedControl] { + let own = (view as? NSSegmentedControl).map { [$0] } ?? [] + return own + view.subviews.flatMap { segmentedControls(in: $0) } + } +} diff --git a/apple/TableProTests/Views/Sidebar/SidebarScopeControlTests.swift b/apple/TableProTests/Views/Sidebar/SidebarScopeControlTests.swift index c94c8f52b0..0eee73249a 100644 --- a/apple/TableProTests/Views/Sidebar/SidebarScopeControlTests.swift +++ b/apple/TableProTests/Views/Sidebar/SidebarScopeControlTests.swift @@ -22,6 +22,19 @@ struct SidebarScopeControlTests { #expect(control.label(forSegment: 1) == String(localized: "Favorites")) } + @Test("macOS 27 draws it as small tabs, earlier releases as a regular control") + func tabsRoleOnMacOS27() { + let control = SidebarScopeControl() + #if SDK_MACOS_27 + if #available(macOS 27.0, *) { + #expect(control.role == .tabs) + #expect(control.controlSize == .small) + return + } + #endif + #expect(control.controlSize == .regular) + } + /// The toolbar version was measured announcing its SF Symbol names, "List" and "favorite". A /// worded segment publishes its own label, measured on macOS 27 as a radio group of two radio /// buttons under the control. diff --git a/apple/TableProTests/Views/UsersRoles/PrivilegeScopeOutlineSelectionTests.swift b/apple/TableProTests/Views/UsersRoles/PrivilegeScopeOutlineSelectionTests.swift new file mode 100644 index 0000000000..66fa8c7ad0 --- /dev/null +++ b/apple/TableProTests/Views/UsersRoles/PrivilegeScopeOutlineSelectionTests.swift @@ -0,0 +1,54 @@ +import AppKit +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +struct PrivilegeScopeOutlineSelectionTests { + // Never ordered front and never closed: `close()` on a test window over-releases it. + private func mount(selecting scopes: Set) throws -> (PrivilegeScopeOutline, PrivilegeScopeOutlineCoordinator) { + let viewModel = UsersRolesViewModel(connectionId: UUID(), databaseType: .postgresql) + viewModel.privilegeTree.showGrantedOnly(scopes: [.database("app"), .database("web")]) + viewModel.selectedScopes = scopes + let coordinator = PrivilegeScopeOutlineCoordinator(viewModel: viewModel) + let scrollView = PrivilegeScopeOutlineView.makeScrollView(coordinator: coordinator) + scrollView.frame = NSRect(x: 0, y: 0, width: 320, height: 160) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 160), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + window.isReleasedWhenClosed = false + window.contentView = scrollView + let outline = try #require(scrollView.documentView as? PrivilegeScopeOutline) + outline.reloadData() + return (outline, coordinator) + } + + private func row(of scope: PluginPrivilegeScope, in outline: NSOutlineView) -> Int? { + (0 ..< outline.numberOfRows).first { (outline.item(atRow: $0) as? PrivilegeNode)?.scope == scope } + } + + @Test("A rebuilt outline selects the rows of the scopes the checklist still edits") + func rebuiltOutlineSelectsTheModelScopes() throws { + let (outline, coordinator) = try mount(selecting: [.database("web")]) + #expect(outline.selectedRowIndexes.isEmpty) + + coordinator.restoreExpansion() + + let web = try #require(row(of: .database("web"), in: outline)) + #expect(outline.selectedRowIndexes == IndexSet(integer: web)) + #expect(coordinator.viewModel.selectedScopes == [.database("web")]) + } + + @Test("A selected scope with no row stays selected in the model") + func scopeWithoutARowIsKept() throws { + let (outline, coordinator) = try mount(selecting: [.database("web"), .database("archive")]) + + coordinator.restoreExpansion() + + #expect(outline.selectedRowIndexes.count == 1) + #expect(coordinator.viewModel.selectedScopes == [.database("web"), .database("archive")]) + } +} diff --git a/apple/TableProUITests/DataFileWindowUITests.swift b/apple/TableProUITests/DataFileWindowUITests.swift index 9dadf9249e..edd0f10f1c 100644 --- a/apple/TableProUITests/DataFileWindowUITests.swift +++ b/apple/TableProUITests/DataFileWindowUITests.swift @@ -23,7 +23,7 @@ final class DataFileWindowUITests: UITestCase { "Row Details must show the selected row, Bob" ) - let cities = window.radioButtons["Cities"].firstMatch + let cities = switcherSegment("Cities", in: window) XCTAssertTrue(cities.waitToExist(timeout: 10), "The window must offer the workbook's other sheet") cities.click() XCTAssertTrue(waitForRowCount("5 rows", in: window), "Choosing a sheet must show that sheet's rows") diff --git a/apple/TableProUITests/QueryPlanResultUITests.swift b/apple/TableProUITests/QueryPlanResultUITests.swift index 460cdf5c2d..2492f07d64 100644 --- a/apple/TableProUITests/QueryPlanResultUITests.swift +++ b/apple/TableProUITests/QueryPlanResultUITests.swift @@ -38,12 +38,13 @@ final class QueryPlanResultUITests: UITestCase { step.click() XCTAssertTrue(detail.waitToExist(timeout: 5), "A click on a step in a zoomed-out diagram must open its details") - let modes = window.radioGroups["query-plan-mode-picker"].firstMatch - modes.radioButtons["Tree"].click() + // A tab group on macOS 27, where the switcher takes the tabs role, and a radio group before it. + let modes = window.descendants(matching: .any)["query-plan-mode-picker"].firstMatch + switcherSegment("Tree", in: modes).click() let outline = window.outlines["query-plan-outline"].firstMatch XCTAssertTrue(outline.waitToExist(timeout: 10), "Tree mode must show the plan outline") XCTAssertGreaterThan(outline.outlineRows.count, 0, "The outline must list the plan's steps") - modes.radioButtons["Diagram"].click() + switcherSegment("Diagram", in: modes).click() XCTAssertTrue(canvas.waitToExist(timeout: 10), "Diagram mode must come back") // Zoom In goes to whatever has focus. Exactly one step from 100% proves the press made from the diff --git a/apple/TableProUITests/Support/UITestCase.swift b/apple/TableProUITests/Support/UITestCase.swift index b8597b1fe3..c45ae89162 100644 --- a/apple/TableProUITests/Support/UITestCase.swift +++ b/apple/TableProUITests/Support/UITestCase.swift @@ -128,6 +128,15 @@ internal class UITestCase: XCTestCase { // MARK: - Finding things + // A view switcher's segment. macOS 27 gives the control the tabs role, which XCUITest reports as a tab + // group of `Tab` elements; before it the segments are radio buttons. + internal func switcherSegment(_ label: String, in container: XCUIElement) -> XCUIElement { + let types = [XCUIElement.ElementType.tab, .radioButton].map(\.rawValue) + return container.descendants(matching: .any) + .matching(NSPredicate(format: "label == %@ AND elementType IN %@", label, types)) + .firstMatch + } + // A window is a direct child of the application; `app.windows` walks the whole tree to find one. internal func window(_ identifier: String, in app: XCUIApplication) -> XCUIElement { app.children(matching: .window).matching(identifier: identifier).firstMatch @@ -276,7 +285,7 @@ internal class UITestCase: XCTestCase { internal func showStructure(in app: XCUIApplication, window: XCUIElement) { showResultView("Structure", in: app) XCTAssertTrue( - window.radioGroups["structure-tab-picker"].firstMatch.waitToExist(timeout: 30), + window.descendants(matching: .any)["structure-tab-picker"].firstMatch.waitToExist(timeout: 30), "The structure editor must open on the Structure result view" ) } diff --git a/apple/changelog.d/3851.changed.md b/apple/changelog.d/3851.changed.md new file mode 100644 index 0000000000..382e8e0624 --- /dev/null +++ b/apple/changelog.d/3851.changed.md @@ -0,0 +1 @@ +Save Changes sits in its own toolbar section, and panes switch views with native tabs across the app. diff --git a/apple/scripts/probes/check-toolbar-autosave-reorder.sh b/apple/scripts/probes/check-toolbar-autosave-reorder.sh new file mode 100755 index 0000000000..b4a6fcc58f --- /dev/null +++ b/apple/scripts/probes/check-toolbar-autosave-reorder.sh @@ -0,0 +1,98 @@ +#!/usr/bin/env bash +# +# Check how NSToolbar's autosaved configuration treats a reordered default item list, which decides whether +# MainWindowToolbar.toolbarIdentifier has to be bumped when defaultItemIdentifiers changes order. +# +# A toolbar nobody customized must take the new order on the next launch, and one a user rearranged must keep +# theirs. Each launch is a separate process under one autosave identifier, as an app update is. +# +# Usage: +# scripts/probes/check-toolbar-autosave-reorder.sh +# +# Writes only to the probe binary's own defaults domain and deletes it at the end. Exits 0 when both cases hold, +# 1 when one does not, and 3 when the check could not run. + +set -uo pipefail + +WORK="$(mktemp -d)" +DOMAIN="toolbar-reorder-probe" +trap 'defaults delete "$DOMAIN" >/dev/null 2>&1; rm -rf "$WORK"' EXIT + +cat > "$WORK/main.swift" <<'SWIFT' +import AppKit + +let ids = ["a", "b", "c", "d"].map { NSToolbarItem.Identifier("probe." + $0) } + +final class Delegate: NSObject, NSToolbarDelegate { + let order: [NSToolbarItem.Identifier] + init(order: [NSToolbarItem.Identifier]) { self.order = order } + func toolbar( + _ toolbar: NSToolbar, + itemForItemIdentifier id: NSToolbarItem.Identifier, + willBeInsertedIntoToolbar flag: Bool + ) -> NSToolbarItem? { + let item = NSToolbarItem(itemIdentifier: id) + item.label = id.rawValue + item.image = NSImage(systemSymbolName: "circle", accessibilityDescription: nil) + return item + } + func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { order } + func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { ids } +} + +let reordered = CommandLine.arguments[1] == "reordered" +let customize = CommandLine.arguments.count > 2 && CommandLine.arguments[2] == "customize" +let app = NSApplication.shared +app.setActivationPolicy(.accessory) +let delegate = Delegate(order: reordered ? [ids[0], ids[2], ids[3], ids[1]] : ids) +let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 600, height: 200), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false +) +let toolbar = NSToolbar(identifier: "probe.reorder.toolbar") +toolbar.delegate = delegate +toolbar.allowsUserCustomization = true +toolbar.autosavesConfiguration = true +window.toolbar = toolbar +window.orderFront(nil) +DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + if customize, let index = toolbar.items.firstIndex(where: { $0.itemIdentifier == ids[3] }) { + toolbar.removeItem(at: index) + toolbar.insertItem(withItemIdentifier: ids[3], at: 0) + } + print(toolbar.items.map { $0.itemIdentifier.rawValue.replacingOccurrences(of: "probe.", with: "") }.joined(separator: ",")) + UserDefaults.standard.synchronize() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { exit(0) } +} +app.run() +SWIFT + +swiftc -O "$WORK/main.swift" -o "$WORK/$DOMAIN" >/dev/null 2>&1 || { + echo "the probe did not compile" >&2 + exit 3 +} + +status=0 +defaults delete "$DOMAIN" >/dev/null 2>&1 +"$WORK/$DOMAIN" original >/dev/null +uncustomized="$("$WORK/$DOMAIN" reordered)" +if [ "$uncustomized" = "a,c,d,b" ]; then + echo "PASS uncustomized toolbar takes the new default order ($uncustomized)" +else + echo "FAIL uncustomized toolbar kept $uncustomized, expected a,c,d,b" + status=1 +fi + +defaults delete "$DOMAIN" >/dev/null 2>&1 +"$WORK/$DOMAIN" original customize >/dev/null +customized="$("$WORK/$DOMAIN" reordered)" +if [ "$customized" = "d,a,b,c" ]; then + echo "PASS customized toolbar keeps the user's order ($customized)" +else + echo "FAIL customized toolbar showed $customized, expected d,a,b,c" + status=1 +fi + +exit $status