From 4823444f8189054ef6f34e3d5811fabd85d3b068 Mon Sep 17 00:00:00 2001 From: wglegola Date: Sun, 19 Jul 2026 13:11:43 +0200 Subject: [PATCH] feat(player): add option to dock lyrics & queue as a side drawer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a General → Behavior setting, "Open lyrics & queue as a side drawer", that docks the lyrics and queue panels as a right-hand column pushing the content aside — like the left sidebar — instead of floating over it as a glass overlay. Default off, so existing floating behavior is unchanged. - SettingsManager: lyricsQueueDrawerEnabled (persisted, default false). - GeneralSettingsView: the toggle in the Behavior section. - MainWindow: a single stable HStack keeps the NavigationSplitView identity across the toggle; drawer mode docks rightDrawerColumn as a sibling column, floating mode keeps the trailing overlay. The drawer's material extends behind the transparent titlebar (full height like the sidebar) while its content stays below it, so the Up Next Clear/Edit controls remain out of the window-drag strip. - LyricsView / QueueView / QueueSidePanelView / SimpleLyricsView: a docked flag renders flush content (no fixed-width glass/material card); the drawer container supplies the background. Queue uses regular material so NSTableView drag-and-drop keeps working. - QueueSidePanelView: the docked side-panel column uses a deterministic width (container width minus a scroller gutter) so rows don't clip on the right edge. - MainWindowLayout: rightDrawerWidth. --- Sources/Kaset/Services/SettingsManager.swift | 11 ++ .../Kaset/Utilities/MainWindowLayout.swift | 4 + Sources/Kaset/Views/GeneralSettingsView.swift | 4 + Sources/Kaset/Views/LegacyFallbackViews.swift | 42 +++-- Sources/Kaset/Views/LyricsView.swift | 42 +++-- Sources/Kaset/Views/MainWindow.swift | 170 ++++++++++++------ Sources/Kaset/Views/QueueSidePanelView.swift | 70 +++++++- Sources/Kaset/Views/QueueView.swift | 43 +++-- 8 files changed, 282 insertions(+), 104 deletions(-) diff --git a/Sources/Kaset/Services/SettingsManager.swift b/Sources/Kaset/Services/SettingsManager.swift index 6fffb860..180fd906 100644 --- a/Sources/Kaset/Services/SettingsManager.swift +++ b/Sources/Kaset/Services/SettingsManager.swift @@ -33,6 +33,7 @@ final class SettingsManager { static let ambientBackdropEnabled = "settings.ambientBackdropEnabled" static let ambientBackdropStyle = "settings.ambientBackdropStyle" static let popOutVideoOnNavigateAway = "settings.popOutVideoOnNavigateAway" + static let lyricsQueueDrawerEnabled = "settings.lyricsQueueDrawerEnabled" #if DEBUG static let useLegacyMacOS15UI = "settings.debug.useLegacyMacOS15UI" #endif @@ -412,6 +413,15 @@ final class SettingsManager { } } + /// When enabled, the Lyrics and Queue panels dock as a right-hand drawer that pushes the + /// content aside (like the left sidebar) instead of floating over it. Defaults to `false` + /// (the floating overlay). + var lyricsQueueDrawerEnabled: Bool { + didSet { + UserDefaults.standard.set(self.lyricsQueueDrawerEnabled, forKey: Keys.lyricsQueueDrawerEnabled) + } + } + /// The style the YouTube watch page should request: the chosen style when /// enabled, `.off` when the feature is disabled. Runtime energy/accessibility /// downgrades are applied inside `AmbientVideoBackdrop`, which observes those @@ -490,6 +500,7 @@ final class SettingsManager { ) self.ambientBackdropEnabled = UserDefaults.standard.object(forKey: Keys.ambientBackdropEnabled) as? Bool ?? true self.popOutVideoOnNavigateAway = UserDefaults.standard.object(forKey: Keys.popOutVideoOnNavigateAway) as? Bool ?? true + self.lyricsQueueDrawerEnabled = UserDefaults.standard.object(forKey: Keys.lyricsQueueDrawerEnabled) as? Bool ?? false #if DEBUG self.useLegacyMacOS15UI = UserDefaults.standard.object(forKey: Keys.useLegacyMacOS15UI) as? Bool ?? false #endif diff --git a/Sources/Kaset/Utilities/MainWindowLayout.swift b/Sources/Kaset/Utilities/MainWindowLayout.swift index ad7494dd..d1f4dad1 100644 --- a/Sources/Kaset/Utilities/MainWindowLayout.swift +++ b/Sources/Kaset/Utilities/MainWindowLayout.swift @@ -15,6 +15,10 @@ enum MainWindowLayout { static let minimumHeight: CGFloat = 600 static let defaultWidth: CGFloat = 1100 static let defaultHeight: CGFloat = 760 + /// Width of the docked lyrics/queue drawer (when the side-drawer setting is enabled). + /// Equals the docked queue-table column width (342) plus the 18pt scroller gutter reserved in + /// `QueueSidePanelView`, so docked rows fill the drawer without clipping under the scroller. + static let rightDrawerWidth: CGFloat = 360 static var minimumContentSize: NSSize { NSSize(width: minimumWidth, height: minimumHeight) diff --git a/Sources/Kaset/Views/GeneralSettingsView.swift b/Sources/Kaset/Views/GeneralSettingsView.swift index 6106ace3..e9004d90 100644 --- a/Sources/Kaset/Views/GeneralSettingsView.swift +++ b/Sources/Kaset/Views/GeneralSettingsView.swift @@ -49,6 +49,10 @@ struct GeneralSettingsView: View { Toggle("Haptic Feedback", isOn: self.$settings.hapticFeedbackEnabled) .help(String(localized: "Provide tactile feedback for actions on Force Touch trackpads")) + // Lyrics & Queue drawer + Toggle("Open lyrics & queue as a side drawer", isOn: self.$settings.lyricsQueueDrawerEnabled) + .help(String(localized: "Dock the lyrics and queue panels as a right-hand drawer that pushes the content aside, like the sidebar, instead of a floating panel")) + // Default Launch Page Picker(String(localized: "Default Page on Launch"), selection: self.$settings.defaultLaunchPage) { ForEach(SettingsManager.LaunchPage.allCases) { page in diff --git a/Sources/Kaset/Views/LegacyFallbackViews.swift b/Sources/Kaset/Views/LegacyFallbackViews.swift index 92f4ea4d..33f70235 100644 --- a/Sources/Kaset/Views/LegacyFallbackViews.swift +++ b/Sources/Kaset/Views/LegacyFallbackViews.swift @@ -229,27 +229,25 @@ struct SimpleLyricsView: View { let client: any YTMusicClientProtocol var showsHeader = true var preferredWidth: CGFloat? = 280 + /// When true, renders flush content for the docked drawer (no fixed width / glass card); + /// the drawer container supplies the background. + var docked = false @State private var lastLoadedVideoId: String? @State private var isLoadingFallback = false var body: some View { - CompatGlassContainer(spacing: 0) { - VStack(spacing: 0) { - if self.showsHeader { - HStack { - Text(String(localized: "Lyrics")) - .font(.headline) - Spacer() - } - .padding() - Divider().opacity(0.3) + Group { + if self.docked { + self.contentStack + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + CompatGlassContainer(spacing: 0) { + self.contentStack + .frame(width: self.preferredWidth) + .compatGlass(interactive: true, in: .rect(cornerRadius: 20)) } - - self.contentView } - .frame(width: self.preferredWidth) - .compatGlass(interactive: true, in: .rect(cornerRadius: 20)) } .onChange(of: self.playerService.currentTrack?.videoId) { _, newVideoId in if let videoId = newVideoId, videoId != self.lastLoadedVideoId { @@ -273,6 +271,22 @@ struct SimpleLyricsView: View { .accessibilityIdentifier(AccessibilityID.Lyrics.fallbackPanel) } + private var contentStack: some View { + VStack(spacing: 0) { + if self.showsHeader { + HStack { + Text(String(localized: "Lyrics")) + .font(.headline) + Spacer() + } + .padding() + Divider().opacity(0.3) + } + + self.contentView + } + } + @ViewBuilder private var contentView: some View { if self.playerService.currentTrack == nil { diff --git a/Sources/Kaset/Views/LyricsView.swift b/Sources/Kaset/Views/LyricsView.swift index 0768edbd..f039f810 100644 --- a/Sources/Kaset/Views/LyricsView.swift +++ b/Sources/Kaset/Views/LyricsView.swift @@ -10,6 +10,9 @@ struct LyricsView: View { let client: any YTMusicClientProtocol var showsHeader = true var preferredWidth: CGFloat? = 280 + /// When true, renders flush content for the docked drawer (no fixed width / glass card); + /// the drawer container supplies the background. + var docked = false @State private var lastLoadedVideoId: String? @State private var isLoadingFallback = false @@ -27,23 +30,20 @@ struct LyricsView: View { @Namespace private var lyricsNamespace var body: some View { - CompatGlassContainer(spacing: 0) { - VStack(spacing: 0) { - if self.showsHeader { - self.headerView - - Divider() - .opacity(0.3) + Group { + if self.docked { + self.contentStack + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + CompatGlassContainer(spacing: 0) { + self.contentStack + .frame(width: self.preferredWidth) + .compatGlass(interactive: true, in: .rect(cornerRadius: 20)) + .compatGlassID("lyricsPanel", in: self.lyricsNamespace) } - - // Content - self.contentView + .compatGlassTransition(.materialize) } - .frame(width: self.preferredWidth) - .compatGlass(interactive: true, in: .rect(cornerRadius: 20)) - .compatGlassID("lyricsPanel", in: self.lyricsNamespace) } - .compatGlassTransition(.materialize) .onChange(of: self.playerService.currentTrack?.videoId) { _, newVideoId in if let videoId = newVideoId, videoId != lastLoadedVideoId { // Reset explanation when track changes @@ -72,6 +72,20 @@ struct LyricsView: View { } } + private var contentStack: some View { + VStack(spacing: 0) { + if self.showsHeader { + self.headerView + + Divider() + .opacity(0.3) + } + + // Content + self.contentView + } + } + private func updateLyricsPolling(for result: LyricResult) { if case let .synced(synced) = result { self.playerService.currentLyricsLineIndex = nil diff --git a/Sources/Kaset/Views/MainWindow.swift b/Sources/Kaset/Views/MainWindow.swift index 4fc82b4e..1063f646 100644 --- a/Sources/Kaset/Views/MainWindow.swift +++ b/Sources/Kaset/Views/MainWindow.swift @@ -398,66 +398,35 @@ struct MainWindow: View { // swiftlint:disable:this type_body_length // MARK: - Main Content private var mainContent: some View { - ZStack(alignment: .trailing) { - // Main navigation content — sidebar and detail swap with the active source. - NavigationSplitView(columnVisibility: self.$columnVisibility) { - if self.settings.appSource == .music { - Sidebar( - selection: self.$navigationSelection, - pinnedSelection: self.$selectedSidebarPinnedItem, - client: self.client, - onReselectNavigationItem: { item in - self.sidebarNavigationReselectGenerations.wrappedValue[item, default: 0] += 1 - if item == .search { - Task { @MainActor in - try? await Task.sleep(for: .milliseconds(100)) - self.searchFocusTrigger.wrappedValue = true - } - } - }, - onReselectPinnedItem: { item in - guard !(self.pinnedNavigationPaths[item.contentId]?.isEmpty ?? true) else { return } - self.pinnedNavigationPaths[item.contentId] = NavigationPath() - } - ) - } else { - YouTubeSidebar( - selection: self.$youtubeNavigationSelection, - onReselect: { _ in - self.youtubeStore.navigationPath = NavigationPath() - } - ) - } - } detail: { - if self.settings.appSource == .music { - self.detailView( - for: self.navigationSelection, - pinnedItem: self.selectedSidebarPinnedItem, - client: self.client - ) - } else { - YouTubeContentView( - selection: self.youtubeNavigationSelection, - store: self.youtubeStore - ) - } - } - .id(self.contentResetID) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { _ in - // Ensure the sidebar returns when the app is re-activated from the Dock or app switcher. - if self.columnVisibility != .all { - self.columnVisibility = .all + // A single stable HStack keeps the NavigationSplitView's identity across the drawer + // setting toggle (a branch swap would rebuild it and reset scroll). Floating mode + // overlays the panel on the content; drawer mode docks it as a sibling column that + // pushes the content aside like the left sidebar. + HStack(spacing: 0) { + self.navigationSplitContent + .overlay(alignment: .trailing) { + if !self.settings.lyricsQueueDrawerEnabled { + // Right sidebar overlay - either lyrics or queue (mutually exclusive) + self.rightSidebarOverlay(client: self.client) + } } - } - // Right sidebar overlay - either lyrics or queue (mutually exclusive) - self.rightSidebarOverlay(client: self.client) + if self.settings.lyricsQueueDrawerEnabled, + self.playerService.showLyrics || self.playerService.showQueue + { + self.rightDrawerColumn(client: self.client) + .transition(.move(edge: .trailing)) + } } .animation(.easeInOut(duration: 0.25), value: self.playerService.showLyrics) .animation(.easeInOut(duration: 0.25), value: self.playerService.showQueue) .frame(minWidth: MainWindowLayout.minimumWidth, minHeight: MainWindowLayout.minimumHeight) .toolbar { + // The AI / command-bar button lives in the window toolbar (titlebar line) so it keeps + // the native toolbar size and stays clickable — the titlebar strip is the OS window-drag + // region, where a manual overlay button would be swallowed. With the drawer docked (its + // header sits below the titlebar), a trailing toolbar item no longer overlaps its + // controls. if self.supportsCommandBarUI { ToolbarItem(placement: .primaryAction) { Button { @@ -475,6 +444,101 @@ struct MainWindow: View { // swiftlint:disable:this type_body_length } } + /// The main sidebar + detail navigation, shared by the floating-overlay and docked-drawer + /// layouts. + private var navigationSplitContent: some View { + NavigationSplitView(columnVisibility: self.$columnVisibility) { + if self.settings.appSource == .music { + Sidebar( + selection: self.$navigationSelection, + pinnedSelection: self.$selectedSidebarPinnedItem, + client: self.client, + onReselectNavigationItem: { item in + self.sidebarNavigationReselectGenerations.wrappedValue[item, default: 0] += 1 + if item == .search { + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(100)) + self.searchFocusTrigger.wrappedValue = true + } + } + }, + onReselectPinnedItem: { item in + guard !(self.pinnedNavigationPaths[item.contentId]?.isEmpty ?? true) else { return } + self.pinnedNavigationPaths[item.contentId] = NavigationPath() + } + ) + } else { + YouTubeSidebar( + selection: self.$youtubeNavigationSelection, + onReselect: { _ in + self.youtubeStore.navigationPath = NavigationPath() + } + ) + } + } detail: { + if self.settings.appSource == .music { + self.detailView( + for: self.navigationSelection, + pinnedItem: self.selectedSidebarPinnedItem, + client: self.client + ) + } else { + YouTubeContentView( + selection: self.youtubeNavigationSelection, + store: self.youtubeStore + ) + } + } + .id(self.contentResetID) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { _ in + // Ensure the sidebar returns when the app is re-activated from the Dock or app switcher. + if self.columnVisibility != .all { + self.columnVisibility = .all + } + } + } + + /// Right drawer column (docked): lyrics or queue rendered flush against the trailing edge, + /// full height, pushing the content aside instead of floating over it. + private func rightDrawerColumn(client: any YTMusicClientProtocol) -> some View { + Group { + if self.playerService.showLyrics { + if !self.usesLegacyMacOS15UI, #available(macOS 26.0, *) { + LyricsView(client: client, docked: true) + } else { + SimpleLyricsView(client: client, docked: true) + } + } else if self.playerService.showQueue { + if self.playerService.queueDisplayMode == .sidepanel { + QueueSidePanelView(docked: true) + } else { + QueueView(docked: true) + } + } + } + .frame(width: MainWindowLayout.rightDrawerWidth) + .frame(maxHeight: .infinity) + // Extend only the material (and its leading divider) up behind the transparent titlebar so + // the drawer reads as full-height like the left sidebar. The CONTENT deliberately stays + // within the safe area (below the titlebar) — the top strip is the window's drag region, so + // pushing the Up Next header up there made its Clear/Edit controls swallow clicks as window + // drags. Keeping content below the titlebar leaves those controls clickable. + .background { + Rectangle() + .fill(.regularMaterial) + .overlay(alignment: .leading) { + // Explicit vertical hairline: a bare `Divider()` in an overlay has no stack axis + // and would render as a horizontal rule across the middle, not a leading seam. + Rectangle() + .fill(Color(nsColor: .separatorColor)) + .frame(width: 1) + .frame(maxHeight: .infinity) + } + .ignoresSafeArea(.container, edges: .top) + } + } + private func presentCommandBarIfAvailable() { guard self.supportsCommandBarUI else { return } self.isCommandBarPresented = true diff --git a/Sources/Kaset/Views/QueueSidePanelView.swift b/Sources/Kaset/Views/QueueSidePanelView.swift index 9509a8fd..75337e11 100644 --- a/Sources/Kaset/Views/QueueSidePanelView.swift +++ b/Sources/Kaset/Views/QueueSidePanelView.swift @@ -9,9 +9,42 @@ struct QueueSidePanelView: View { @Environment(FavoritesManager.self) private var favoritesManager @Environment(SongLikeStatusManager.self) private var likeStatusManager + /// When true, renders flush content for the docked drawer (no fixed width / rounded card); + /// the drawer container supplies the background. + var docked: Bool = false + + /// Width of the non-docked floating side panel. + private static let floatingPanelWidth: CGFloat = 400 + /// Trailing space reserved for the vertical scroller so it doesn't cover the duration label. + private static let scrollerGutter: CGFloat = 18 + + /// Fixed table-column width for the current layout: the container width (the docked drawer or the + /// floating panel) minus the scroller gutter. Deterministic — no dependence on live scroll-view + /// geometry, which during the drawer's slide-in transition briefly reports the full window width. + private var tableColumnWidth: CGFloat { + let container = self.docked ? MainWindowLayout.rightDrawerWidth : Self.floatingPanelWidth + return container - Self.scrollerGutter + } + var body: some View { // Use regular material: GlassEffectContainer breaks NSTableView drag-and-drop - // (drop target gap and acceptDrop never fire when the table is inside glass). + // (drop target gap and acceptDrop never fire when the table is inside glass). The docked + // drawer likewise backs the panel with regular material, not glass, to keep drag working. + Group { + if self.docked { + self.contentStack + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + self.contentStack + .frame(width: Self.floatingPanelWidth) + .background(.regularMaterial) + .clipShape(RoundedRectangle(cornerRadius: 20)) + } + } + .accessibilityIdentifier(AccessibilityID.Queue.container) + } + + private var contentStack: some View { VStack(spacing: 0) { QueueSidePanelHeader() @@ -28,6 +61,7 @@ struct QueueSidePanelView: View { favoritesManager: self.favoritesManager, likeStatusManager: self.likeStatusManager, allowsLikeActions: self.authService.hasPersonalAccount, + columnWidth: self.tableColumnWidth, likeStatusEvent: self.likeStatusManager.lastLikeEvent, onSelect: { entryID in let reservation = self.playerService.reserveMusicPlaybackIntent() @@ -60,10 +94,6 @@ struct QueueSidePanelView: View { QueueFooterActions() } - .frame(width: 400) - .background(.regularMaterial) - .clipShape(RoundedRectangle(cornerRadius: 20)) - .accessibilityIdentifier(AccessibilityID.Queue.container) } private var emptyQueueView: some View { @@ -96,6 +126,10 @@ struct QueueListControllerRepresentable: NSViewControllerRepresentable { let favoritesManager: FavoritesManager let likeStatusManager: SongLikeStatusManager let allowsLikeActions: Bool + /// Fixed width for the single table column. Set from known container widths (the docked drawer or + /// the floating panel) minus a scroller gutter — deterministic, so it never depends on reading + /// transient scroll-view geometry during layout. + let columnWidth: CGFloat /// Observed to refresh visible AppKit rows after optimistic like updates and rollbacks. let likeStatusEvent: LikeStatusEvent? let onSelect: (UUID) -> Void @@ -105,12 +139,20 @@ struct QueueListControllerRepresentable: NSViewControllerRepresentable { func makeNSViewController(context: Context) -> QueueListViewController { let viewController = QueueListViewController() + viewController.columnWidth = self.columnWidth viewController.coordinator = context.coordinator context.coordinator.viewController = viewController return viewController } func updateNSViewController(_ viewController: QueueListViewController, context: Context) { + viewController.columnWidth = self.columnWidth + if let column = viewController.tableView?.tableColumns.first, + abs(column.width - self.columnWidth) > 0.5 + { + column.width = self.columnWidth + } + context.coordinator.favoritesManager = self.favoritesManager context.coordinator.likeStatusManager = self.likeStatusManager context.coordinator.allowsLikeActions = self.allowsLikeActions @@ -165,6 +207,10 @@ struct QueueListControllerRepresentable: NSViewControllerRepresentable { class QueueListViewController: NSViewController { var tableView: DraggableTableView? weak var coordinator: Coordinator? + /// Fixed column width, supplied by the representable (container width minus scroller gutter). + /// The initial value is a never-used placeholder — `makeNSViewController` sets the real width + /// before `loadView` reads it. + var columnWidth: CGFloat = 0 override func loadView() { let scrollView = NSScrollView() @@ -182,15 +228,21 @@ struct QueueListControllerRepresentable: NSViewControllerRepresentable { tableView.backgroundColor = .clear tableView.allowsEmptySelection = true tableView.allowsColumnResizing = false - tableView.columnAutoresizingStyle = .uniformColumnAutoresizingStyle + // The single column uses a fixed width supplied by the representable (container width minus + // a scroller gutter). Autoresizing is off so the table never stretches the column to the + // full clip width — which would push the trailing-pinned duration under the overlay + // scroller (and, mid-transition, past the clip edge where it gets cut off entirely). + tableView.columnAutoresizingStyle = .noColumnAutoresizing tableView.intercellSpacing = NSSize(width: 0, height: 0) tableView.rowHeight = 56 let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("QueueColumn")) column.title = "" - column.minWidth = 350 - column.maxWidth = 400 - column.width = 350 // Matches container width minus scroll bar space + // Low min / effectively-unbounded max: the width is set explicitly to `columnWidth`, and + // these only guard against absurd values — they must never clamp it ABOVE the visible width. + column.minWidth = 200 + column.maxWidth = 10000 + column.width = self.columnWidth tableView.addTableColumn(column) let dragType = NSPasteboard.PasteboardType("com.kaset.queueitem") diff --git a/Sources/Kaset/Views/QueueView.swift b/Sources/Kaset/Views/QueueView.swift index b60b421c..f1284199 100644 --- a/Sources/Kaset/Views/QueueView.swift +++ b/Sources/Kaset/Views/QueueView.swift @@ -12,26 +12,41 @@ struct QueueView: View { /// Namespace for glass effect morphing. @Namespace private var queueNamespace - var body: some View { - CompatGlassContainer(spacing: 0) { - VStack(spacing: 0) { - // Header - self.headerView - - Divider() - .opacity(0.3) + /// When true, renders flush content for the docked drawer (no fixed width / glass card); + /// the drawer container supplies the background. + var docked: Bool = false - // Content - self.contentView + var body: some View { + Group { + if self.docked { + self.contentStack + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + CompatGlassContainer(spacing: 0) { + self.contentStack + .frame(width: 280) + .compatGlass(interactive: true, in: .rect(cornerRadius: 20)) + .compatGlassID("queuePanel", in: self.queueNamespace) + } + .compatGlassTransition(.materialize) } - .frame(width: 280) - .compatGlass(interactive: true, in: .rect(cornerRadius: 20)) - .compatGlassID("queuePanel", in: self.queueNamespace) } - .compatGlassTransition(.materialize) .accessibilityIdentifier(AccessibilityID.Queue.container) } + private var contentStack: some View { + VStack(spacing: 0) { + // Header + self.headerView + + Divider() + .opacity(0.3) + + // Content + self.contentView + } + } + // MARK: - Header private var headerView: some View {