Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import CoreGraphics
import SwiftUI

struct SettingsSectionFramePreferenceKey: PreferenceKey {
static let defaultValue: [SettingsSectionID: CGRect] = [:]

/// Merges section frames reported by every visibility marker.
///
/// - Parameters:
/// - value: Current aggregate section frame map.
/// - nextValue: Next lazily produced section frame map from SwiftUI.
static func reduce(
value: inout [SettingsSectionID: CGRect],
nextValue: () -> [SettingsSectionID: CGRect]
) {
value.merge(nextValue()) { _, newValue in newValue }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
struct SettingsSectionVisibilityCoordinateSpace {
static let name = "SettingsSectionVisibilityCoordinateSpace"

private init() {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import SwiftUI

struct SettingsSectionVisibilityMarker: View {
let section: SettingsSectionID

var body: some View {
GeometryReader { proxy in
Color.clear.preference(
key: SettingsSectionFramePreferenceKey.self,
value: [section: proxy.frame(in: .named(SettingsSectionVisibilityCoordinateSpace.name))]
)
}
}
}

extension View {
/// Reports this view's frame as the scroll-position marker for a settings section.
///
/// - Parameter section: Section represented by the view.
/// - Returns: A view that publishes its frame through `SettingsSectionFramePreferenceKey`.
func settingsSectionVisibility(_ section: SettingsSectionID) -> some View {
background(SettingsSectionVisibilityMarker(section: section))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,11 @@ import SwiftUI
/// than wrap and inflate row height.
@MainActor
struct SettingsSidebarEntryRow: View {
let id: String
let title: String
let symbolName: String
let subtitle: String?
let isSelected: Bool

var body: some View {
HStack(spacing: 10) {
Expand All @@ -32,5 +34,7 @@ struct SettingsSidebarEntryRow: View {
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.accessibilityIdentifier("SettingsSidebarEntry.\(id)")
.accessibilityAddTraits(isSelected ? .isSelected : [])
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import CoreGraphics

struct SettingsVisibleSectionResolver: Sendable {
struct Configuration: Sendable {
static let defaultActivationY: CGFloat = 0

let activationY: CGFloat

/// Creates a resolver configuration.
///
/// - Parameter activationY: Vertical viewport coordinate that decides when
/// a section becomes active. A section is active after its top edge
/// crosses this coordinate.
init(activationY: CGFloat = Self.defaultActivationY) {
self.activationY = activationY
}
}

/// Resolves the section that should be selected for the current scroll position.
///
/// - Parameters:
/// - frames: Current section frames in the settings detail scroll coordinate space.
/// - orderedSections: Sections in visual scroll order.
/// - configuration: Resolver options, including the activation line.
/// - Returns: The active section, or `nil` when no tracked frames are available.
static func visibleSection(
in frames: [SettingsSectionID: CGRect],
orderedSections: [SettingsSectionID] = SettingsSectionID.allCases,
configuration: Configuration = Configuration()
) -> SettingsSectionID? {
let orderedFrames = orderedSections.enumerated().compactMap { index, section -> SectionFrame? in
guard let frame = frames[section] else { return nil }
return SectionFrame(index: index, section: section, frame: frame)
}

guard !orderedFrames.isEmpty else { return nil }

let containingFrames = orderedFrames.filter {
$0.frame.minY <= configuration.activationY && $0.frame.maxY >= configuration.activationY
}
if let active = nearestFrameToActivationLine(in: containingFrames) {
return active.section
}

let crossedFrames = orderedFrames.filter { $0.frame.minY <= configuration.activationY }
if let active = nearestFrameToActivationLine(in: crossedFrames) {
return active.section
}

return orderedFrames.min(by: { lhs, rhs in
if lhs.frame.minY == rhs.frame.minY {
return lhs.index < rhs.index
}
return lhs.frame.minY < rhs.frame.minY
})?.section
}

/// Selects the frame whose top edge is closest to the activation line.
///
/// - Parameter frames: Section frames already filtered to eligible candidates.
/// - Returns: The nearest eligible frame, or `nil` when no candidates exist.
private static func nearestFrameToActivationLine(in frames: [SectionFrame]) -> SectionFrame? {
if let active = frames.max(by: { lhs, rhs in
if lhs.frame.minY == rhs.frame.minY {
return lhs.index < rhs.index
}
return lhs.frame.minY < rhs.frame.minY
}) {
return active
}
return nil
}

private struct SectionFrame {
let index: Int
let section: SettingsSectionID
let frame: CGRect
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@ public struct SettingsWindowRoot: View {
// and re-checked inside the scheduled `Task { @MainActor in ... }`,
// so only the most recent request actually scrolls.
@State private var settingsNavigationGeneration: Int = 0
// Prevents the reverse "visible section -> sidebar selection" sync from
// racing the restored or clicked navigation target while `scrollTo` is
// still settling. It starts true so first-layout frame reports cannot
// overwrite the SceneStorage-restored section before `onAppear` scrolls
// there.
@State private var visibleSectionSyncSuppressed: Bool = true
@State private var latestSectionFrames: [SettingsSectionID: CGRect] = [:]
// Drives the "flash the navigated-to row" affordance the legacy
// settings window had. When the user clicks a search hit, the target
// row pulses an accent border for a few seconds so the eye can find
Expand Down Expand Up @@ -146,6 +153,7 @@ public struct SettingsWindowRoot: View {
// selected.
guard newValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
selectedSidebarEntryID = sectionEntryID(for: selectedSection)
syncSidebarSelectionToVisibleSection(latestSectionFrames)
}
}

Expand Down Expand Up @@ -187,9 +195,11 @@ public struct SettingsWindowRoot: View {
} else {
ForEach(matches) { entry in
SettingsSidebarEntryRow(
id: entry.id,
title: entry.title,
symbolName: entry.symbolName,
subtitle: subtitle(for: entry)
subtitle: subtitle(for: entry),
isSelected: selectedSidebarEntryID == entry.id
)
.tag(entry.id)
}
Expand Down Expand Up @@ -341,7 +351,13 @@ public struct SettingsWindowRoot: View {
.padding(.top, 20)
.padding(.bottom, 20)
}
.coordinateSpace(name: SettingsSectionVisibilityCoordinateSpace.name)
.accessibilityIdentifier("SettingsDetailScrollView")
.toggleStyle(.switch)
.onPreferenceChange(SettingsSectionFramePreferenceKey.self) { frames in
latestSectionFrames = frames
syncSidebarSelectionToVisibleSection(frames)
}
.onAppear {
// Legacy SettingsView.onAppear scrolls to the restored
// section so reopening the Settings window lands on
Expand Down Expand Up @@ -392,6 +408,7 @@ public struct SettingsWindowRoot: View {
let sectionID = self.anchorID(for: target)
settingsNavigationGeneration += 1
let navigationGeneration = settingsNavigationGeneration
visibleSectionSyncSuppressed = true
// Arm (or clear) the highlight before the scroll so the pulse is
// already live when the target lands in view. A section hit
// (anchorID == sectionID) highlights the section header; a row
Expand Down Expand Up @@ -422,6 +439,30 @@ public struct SettingsWindowRoot: View {
Task { @MainActor in
guard navigationGeneration == settingsNavigationGeneration else { return }
proxy.scrollTo(anchorID, anchor: anchor)
await Task.yield()
guard navigationGeneration == settingsNavigationGeneration else { return }
visibleSectionSyncSuppressed = false
}
}

/// Syncs the sidebar's selected row to the currently visible detail section.
///
/// - Parameter frames: Section frames reported in the settings detail scroll
/// coordinate space.
private func syncSidebarSelectionToVisibleSection(_ frames: [SettingsSectionID: CGRect]) {
guard !visibleSectionSyncSuppressed else { return }
// Search results can select a specific setting row, not just a section.
// Keep that deep selection stable until the search query is cleared.
guard !isSearching else { return }
guard let visibleSection = SettingsVisibleSectionResolver.visibleSection(in: frames) else { return }

if selectedSectionRaw != visibleSection.rawValue {
selectedSectionRaw = visibleSection.rawValue
}

let sidebarEntryID = sectionEntryID(for: visibleSection)
if selectedSidebarEntryID != sidebarEntryID {
selectedSidebarEntryID = sidebarEntryID
}
}

Expand All @@ -436,13 +477,15 @@ public struct SettingsWindowRoot: View {
catalog: catalog,
accountFlow: accountFlow
)
.settingsSectionVisibility(.account)
.id(anchorID(for: .account))

AppSection(
defaultsStore: defaultsStore,
catalog: catalog,
hostActions: hostActions
)
.settingsSectionVisibility(.app)
.id(anchorID(for: .app))

TerminalSection(
Expand All @@ -451,18 +494,23 @@ public struct SettingsWindowRoot: View {
catalog: catalog,
hostActions: hostActions
)
.settingsSectionVisibility(.terminal)
.id(anchorID(for: .terminal))

TextBoxSection(defaultsStore: defaultsStore, catalog: catalog)
.settingsSectionVisibility(.textBox)
.id(anchorID(for: .textBox))

MobileSection(defaultsStore: defaultsStore, catalog: catalog, hostActions: hostActions)
.settingsSectionVisibility(.mobile)
.id(anchorID(for: .mobile))

SidebarSection(defaultsStore: defaultsStore, catalog: catalog, hostActions: hostActions)
.settingsSectionVisibility(.sidebarAppearance)
.id(anchorID(for: .sidebarAppearance))

BetaFeaturesSection(defaultsStore: defaultsStore, catalog: catalog)
.settingsSectionVisibility(.betaFeatures)
.id(anchorID(for: .betaFeatures))

AutomationSection(
Expand All @@ -472,6 +520,7 @@ public struct SettingsWindowRoot: View {
catalog: catalog,
errorLog: runtime.errorLog
)
.settingsSectionVisibility(.automation)
.id(anchorID(for: .automation))

BrowserSection(
Expand All @@ -480,6 +529,7 @@ public struct SettingsWindowRoot: View {
hostActions: hostActions,
importAnchorID: anchorID(for: .browserImport)
)
.settingsSectionVisibility(.browser)
.id(anchorID(for: .browser))

GlobalHotkeySection(
Expand All @@ -488,6 +538,7 @@ public struct SettingsWindowRoot: View {
catalog: catalog,
errorLog: runtime.errorLog
)
.settingsSectionVisibility(.globalHotkey)
.id(anchorID(for: .globalHotkey))

KeyboardShortcutsSection(
Expand All @@ -496,6 +547,7 @@ public struct SettingsWindowRoot: View {
errorLog: runtime.errorLog,
hostActions: hostActions
)
.settingsSectionVisibility(.keyboardShortcuts)
.id(anchorID(for: .keyboardShortcuts))

WorkspaceColorsSection(
Expand All @@ -504,16 +556,19 @@ public struct SettingsWindowRoot: View {
catalog: catalog,
errorLog: runtime.errorLog
)
.settingsSectionVisibility(.workspaceColors)
.id(anchorID(for: .workspaceColors))

SettingsJSONSection(jsonStore: jsonStore, hostActions: hostActions)
.settingsSectionVisibility(.settingsJSON)
.id(anchorID(for: .settingsJSON))

ResetSection(
defaultsStore: defaultsStore,
jsonStore: jsonStore,
catalog: catalog
)
.settingsSectionVisibility(.reset)
.id(anchorID(for: .reset))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,7 @@ public struct BrowserSection: View {
importHintModel: importHint,
onImport: { hostActions.openBrowserImportFlow() }
)
.settingsSectionVisibility(.browserImport)
.id(importAnchorID ?? "section:browserImport.inline")
.settingsSearchHighlight([importAnchorID, "setting:browserImport:import-data"].compactMap { $0 })
SettingsCardDivider()
Expand Down
Loading