diff --git a/Chorus/App/AppState.swift b/Chorus/App/AppState.swift index f196920..8520c5f 100644 --- a/Chorus/App/AppState.swift +++ b/Chorus/App/AppState.swift @@ -115,6 +115,15 @@ final class AppState { /// launch; the Settings picker writes both this and the persisted value. var railLayout: RailLayout = .sidebar + /// Hides the spaces rail. Purely visual — every other route to a space + /// (⌘K, Ctrl-Tab, the menu bar) keeps working, and the active space is + /// unchanged. Written via `setHideSpacesUI(_:)`. + var hideSpacesUI = false + + /// Where the navigation buttons sit. Loaded from AppPreferences at launch; + /// written via `setToolbarPosition(_:)`. + var toolbarPosition: ToolbarPosition = .top + /// App-level appearance override, loaded from AppPreferences. var appearanceMode: AppearanceMode = .system @@ -1643,6 +1652,8 @@ final class AppState { Task { await FaviconFetcher.shared.setGoogleFallbackEnabled(googleFallback) } autoHibernateIdleEnabled = prefs?.autoHibernateIdleEnabledEffective ?? false autoHibernateIdleMinutes = prefs?.autoHibernateIdleMinutesEffective ?? 10 + hideSpacesUI = prefs?.hideSpacesUIEffective ?? false + toolbarPosition = prefs?.toolbarPosition ?? .top defaultCameraPolicy = prefs?.defaultCameraPolicyRaw.flatMap(MediaPermissionPolicy.init(rawValue:)) ?? .ask defaultMicrophonePolicy = prefs?.defaultMicrophonePolicyRaw.flatMap(MediaPermissionPolicy.init(rawValue:)) ?? .ask // Start locked at launch when opted in; ContentView's lock overlay @@ -1745,6 +1756,33 @@ final class AppState { } Task { await FaviconFetcher.shared.setGoogleFallbackEnabled(enabled) } } + /// Shows or hides the spaces rail and persists the choice. Visual only: the + /// selected space is untouched, so nothing needs re-attaching. + func setHideSpacesUI(_ hidden: Bool) { + hideSpacesUI = hidden + let prefs = ensurePreferences() + prefs.hideSpacesUI = hidden + do { + try modelContainer.mainContext.save() + } catch { + AppLogger.dataStore.error("Failed to save hide-spaces toggle: \(error.localizedDescription)") + modelContainer.mainContext.rollback() + } + } + + /// Moves the navigation buttons and persists the choice. + func setToolbarPosition(_ position: ToolbarPosition) { + toolbarPosition = position + let prefs = ensurePreferences() + prefs.toolbarPositionRaw = position.rawValue + do { + try modelContainer.mainContext.save() + } catch { + AppLogger.dataStore.error("Failed to save toolbar position: \(error.localizedDescription)") + modelContainer.mainContext.rollback() + } + } + /// Flips annoyance hiding, persists it, and re-attaches lists to live views. func setAnnoyanceBlockingEnabled(_ enabled: Bool) { diff --git a/Chorus/App/ChorusApp.swift b/Chorus/App/ChorusApp.swift index 5dd5d04..ae905d5 100644 --- a/Chorus/App/ChorusApp.swift +++ b/Chorus/App/ChorusApp.swift @@ -98,6 +98,15 @@ struct ChorusApp: App { ) CommandGroup(after: .toolbar) { + // Show/hide on demand, so the bar can come back for the + // occasional space switch without a trip through Settings. + Button(appState.hideSpacesUI ? "Show Spaces Bar" : "Hide Spaces Bar") { + appState.setHideSpacesUI(!appState.hideSpacesUI) + } + .keyboardShortcut("s", modifiers: [.control, .command]) + + Divider() + Button("Reload") { appState.reloadActiveService() } diff --git a/Chorus/Models/AppPreferences.swift b/Chorus/Models/AppPreferences.swift index ef84122..b5ad6d1 100644 --- a/Chorus/Models/AppPreferences.swift +++ b/Chorus/Models/AppPreferences.swift @@ -25,6 +25,23 @@ enum RailLayout: String, Codable, CaseIterable { } } +/// Where the back/forward/reload/home buttons sit. +enum ToolbarPosition: String, Codable, CaseIterable { + /// Above the page (the default). In the horizontal rail layouts these live + /// in the tab bar rather than a row of their own. + case top + /// In a bar under the page, clear of the traffic lights and the badge + /// cluster at the top-left. + case bottom + + var displayName: String { + switch self { + case .top: return "Top" + case .bottom: return "Bottom" + } + } +} + /// App-level light/dark appearance override. enum AppearanceMode: String, Codable, CaseIterable { case system @@ -116,6 +133,15 @@ final class AppPreferences { /// Idle minutes before auto-hibernation kicks in. Optional; nil resolves to 10. var autoHibernateIdleMinutes: Int? + /// Hides the spaces rail. Optional for SwiftData lightweight migration; nil + /// is treated as false, so the rail keeps showing on upgrade. Visual only — + /// the selected space and every other way to reach one are untouched. + var hideSpacesUI: Bool? + + /// Where the navigation buttons sit. Optional for SwiftData lightweight + /// migration; nil or unknown resolves to `.top`. Read via `toolbarPosition`. + var toolbarPositionRaw: String? + init( id: UUID = UUID(), appPresenceMode: AppPresenceMode = .dock, @@ -140,7 +166,9 @@ final class AppPreferences { defaultMicrophonePolicyRaw: String? = nil, googleFaviconFallbackEnabled: Bool? = nil, autoHibernateIdleEnabled: Bool? = nil, - autoHibernateIdleMinutes: Int? = nil + autoHibernateIdleMinutes: Int? = nil, + hideSpacesUI: Bool? = nil, + toolbarPositionRaw: String? = nil ) { self.id = id self.appPresenceMode = appPresenceMode @@ -166,6 +194,8 @@ final class AppPreferences { self.googleFaviconFallbackEnabled = googleFaviconFallbackEnabled self.autoHibernateIdleEnabled = autoHibernateIdleEnabled self.autoHibernateIdleMinutes = autoHibernateIdleMinutes + self.hideSpacesUI = hideSpacesUI + self.toolbarPositionRaw = toolbarPositionRaw } /// Materialises the storage-optional default zoom (nil → 1.0). @@ -197,4 +227,13 @@ final class AppPreferences { var autoHibernateIdleMinutesEffective: Int { min(120, max(1, autoHibernateIdleMinutes ?? 10)) } + + /// Materialises the storage-optional hide-spaces flag (nil → false, so the + /// spaces rail keeps showing for everyone upgrading into the setting). + var hideSpacesUIEffective: Bool { hideSpacesUI ?? false } + + /// Resolves the stored toolbar position, defaulting unknown/legacy to `.top`. + var toolbarPosition: ToolbarPosition { + toolbarPositionRaw.flatMap(ToolbarPosition.init(rawValue:)) ?? .top + } } diff --git a/Chorus/Views/MainWindow/ContentView.swift b/Chorus/Views/MainWindow/ContentView.swift index 6c36106..6feb12b 100644 --- a/Chorus/Views/MainWindow/ContentView.swift +++ b/Chorus/Views/MainWindow/ContentView.swift @@ -1,6 +1,18 @@ import SwiftUI import SwiftData +/// Fixed metrics of the window's chrome. The title bar is hidden, so these +/// insets are what keep content clear of the traffic lights — shared rather than +/// repeated, because a rail and the view beside it drifting apart by a few +/// points is exactly the kind of misalignment nobody notices until it ships. +enum WindowChrome { + /// Height of the traffic-light strip. + static let lightsHeight: CGFloat = 28 + /// Width the traffic lights occupy, for insetting content that starts at the + /// window's leading edge. + static let lightsWidth: CGFloat = 72 +} + struct ContentView: View { @Environment(AppState.self) private var appState @@ -146,37 +158,58 @@ struct ContentView: View { ) -> some View { // The title bar is hidden, so content runs to the top edge. Reserve the // top-left for the traffic lights: push the leftmost top elements clear. - let lightsHeight: CGFloat = 28 - let lightsWidth: CGFloat = 72 + let lightsHeight = WindowChrome.lightsHeight + let lightsWidth = WindowChrome.lightsWidth let railWidth: CGFloat = 52 + // With the spaces rail hidden, whatever sits at the top-left inherits the + // job of clearing the traffic lights. In `.sidebar` the services rail + // already reserves `lightsHeight`, but the horizontal service tabs in + // `.topBars`/`.hybrid` were only inset on the assumption that the spaces + // rail was there to their left (or above), so they need the full + // `lightsWidth` once it goes away. + let spacesHidden = appState.hideSpacesUI + let hybridTabInset = spacesHidden ? lightsWidth : lightsWidth - railWidth + switch appState.railLayout { case .sidebar: HStack(spacing: 0) { - spacesRail(axis: .vertical, selection: spaceSelection, contentInset: lightsHeight) - Divider() + // The vertical rules start below the traffic-light band rather + // than running the full height. Otherwise they cut across the + // title strip and chop the top of the window into segments; the + // rails already leave that band clear, so the rules should too. + if !spacesHidden { + spacesRail(axis: .vertical, selection: spaceSelection, contentInset: lightsHeight) + Divider().padding(.top, lightsHeight) + } if let spaceID = appState.selectedSpaceID { servicesRail(axis: .vertical, spaceID: spaceID, selection: serviceSelection, contentInset: lightsHeight) - Divider() + Divider().padding(.top, lightsHeight) } webContent } case .topBars: VStack(spacing: 0) { - spacesRail(axis: .horizontal, selection: spaceSelection, contentInset: lightsWidth) - Divider() + if !spacesHidden { + spacesRail(axis: .horizontal, selection: spaceSelection, contentInset: lightsWidth) + Divider() + } if let spaceID = appState.selectedSpaceID { - servicesRail(axis: .horizontal, spaceID: spaceID, selection: serviceSelection) + servicesRail( + axis: .horizontal, spaceID: spaceID, selection: serviceSelection, + contentInset: spacesHidden ? lightsWidth : 0) } webContent } case .hybrid: HStack(spacing: 0) { - spacesRail(axis: .vertical, selection: spaceSelection, contentInset: lightsHeight) - Divider() + if !spacesHidden { + spacesRail(axis: .vertical, selection: spaceSelection, contentInset: lightsHeight) + Divider() + } VStack(spacing: 0) { if let spaceID = appState.selectedSpaceID { - servicesRail(axis: .horizontal, spaceID: spaceID, selection: serviceSelection, contentInset: lightsWidth - railWidth) + servicesRail(axis: .horizontal, spaceID: spaceID, selection: serviceSelection, contentInset: hybridTabInset) } webContent } diff --git a/Chorus/Views/MainWindow/ServiceSidebarView.swift b/Chorus/Views/MainWindow/ServiceSidebarView.swift index e8dcf72..44242d0 100644 --- a/Chorus/Views/MainWindow/ServiceSidebarView.swift +++ b/Chorus/Views/MainWindow/ServiceSidebarView.swift @@ -241,6 +241,7 @@ struct ServiceSidebarView: View { Divider() + spacesToggleButton addServiceButton } .frame(width: 52) @@ -257,9 +258,12 @@ struct ServiceSidebarView: View { Spacer(minLength: 40) // Nav buttons live at the far right of the tab bar (top-right corner - // of the window), acting on the active service. - WebNavButtons(webViewState: appState.webViewState, homeURL: activeHomeURL) - .padding(.trailing, 10) + // of the window), acting on the active service — unless the user + // moved them to the bottom bar, which owns them for every layout. + if appState.toolbarPosition == .top { + WebNavButtons(webViewState: appState.webViewState, homeURL: activeHomeURL) + .padding(.trailing, 10) + } } // Headroom above the row. In the hybrid layout this row sits at the very // top of the window, and the icon-tab badge pokes ~2pt past its icon's @@ -318,6 +322,7 @@ struct ServiceSidebarView: View { .id(link.service.id) } addServiceButton + spacesToggleButton } .padding(.leading, 8 + contentInset) .padding(.trailing, 8) @@ -479,6 +484,29 @@ struct ServiceSidebarView: View { focusedServiceID = link.service.id } + /// Show/hide the spaces rail. It lives in the SERVICES rail on purpose: the + /// spaces rail is what disappears, so a control hosted there would take + /// itself away and leave no way back except the menu. + private var spacesToggleButton: some View { + Button { + appState.setHideSpacesUI(!appState.hideSpacesUI) + } label: { + Image(systemName: "sidebar.left") + .font(.system(size: 12, weight: .medium)) + .frame( + width: axis == .vertical ? 44 : 36, + height: axis == .vertical ? 32 : ServiceTabView.height + ) + // Dimmed while hidden, so the button reports the current state + // instead of just offering the action. + .foregroundStyle(appState.hideSpacesUI ? .tertiary : .secondary) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(appState.hideSpacesUI ? "Show spaces" : "Hide spaces") + .accessibilityLabel(appState.hideSpacesUI ? "Show spaces" : "Hide spaces") + } + private var addServiceButton: some View { Button { showingAddService = true diff --git a/Chorus/Views/MainWindow/WebContentView.swift b/Chorus/Views/MainWindow/WebContentView.swift index d760612..86e9a1e 100644 --- a/Chorus/Views/MainWindow/WebContentView.swift +++ b/Chorus/Views/MainWindow/WebContentView.swift @@ -28,14 +28,18 @@ struct WebContentView: View { passkeyNoticeBanner } - // Horizontal layouts host the nav buttons in the top tab bar; the - // sidebar layout shows them in a slim row above the content. + // Reserve the traffic-light strip. Rendered for both toolbar + // positions so the top of the window reads the same either way. if appState.railLayout == .sidebar { - WebNavButtons(webViewState: webViewState, homeURL: URL(string: service.url)) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(nsColor: .windowBackgroundColor)) + titleBand + } + + // Horizontal layouts host the nav buttons in the top tab bar; the + // sidebar layout shows them in a slim row above the content. Both + // are suppressed when the buttons have been moved to the bottom + // bar below, so they never appear twice. + if appState.railLayout == .sidebar, appState.toolbarPosition == .top { + navButtonRow(for: service) Divider() } @@ -69,6 +73,14 @@ struct WebContentView: View { } .animation(reduceMotion ? nil : .easeOut(duration: 0.2), value: webViewState.isLoading) .animation(reduceMotion ? nil : .easeOut(duration: 0.18), value: appState.findInPageVisible) + + // Bottom bar. Applies to every rail layout, which is the point: + // at the top the buttons crowd the traffic lights and the badge + // cluster, and down here they have the width to themselves. + if appState.toolbarPosition == .bottom { + Divider() + navButtonRow(for: service) + } } else if selectedService != nil { ProgressView("Loading service…") .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -100,6 +112,26 @@ struct WebContentView: View { } } + /// Reserves the traffic-light strip so the nav row below clears the + /// close/minimise/zoom dots. Empty on purpose — AppKit's title bar is drawn + /// over this area, so nothing rendered here reaches the screen; it only holds + /// vertical space. + private var titleBand: some View { + Color.clear + .frame(height: WindowChrome.lightsHeight) + .allowsHitTesting(false) + } + + /// The slim navigation row, shared by the top and bottom placements so the + /// two can't drift apart. + private func navButtonRow(for service: ServiceInstance) -> some View { + WebNavButtons(webViewState: webViewState, homeURL: URL(string: service.url)) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(nsColor: .windowBackgroundColor)) + } + private func loadWebViewForSelectedService() { // Stop the outgoing service's active poll — but only if the pool still // regards it as the active service. On a deep-link switch AppState has diff --git a/Chorus/Views/Settings/SettingsView.swift b/Chorus/Views/Settings/SettingsView.swift index c110b62..2f8d59f 100644 --- a/Chorus/Views/Settings/SettingsView.swift +++ b/Chorus/Views/Settings/SettingsView.swift @@ -118,6 +118,28 @@ struct GeneralSettingsView: View { Text(layout.displayName).tag(layout) } } + + Picker("Navigation buttons", selection: Binding( + get: { prefs.toolbarPosition }, + set: { position in + appState.setToolbarPosition(position) + } + )) { + ForEach(ToolbarPosition.allCases, id: \.self) { position in + Text(position.displayName).tag(position) + } + } + + Toggle("Hide the spaces bar", isOn: Binding( + get: { prefs.hideSpacesUIEffective }, + set: { value in + appState.setHideSpacesUI(value) + } + )) + + Text("Hiding the bar only affects what you see. Switch spaces with ⌘K or Ctrl-Tab, or from the menu bar. Services in a space you aren't viewing stay out of the sidebar, so reach them with ⌘K.") + .font(.caption) + .foregroundStyle(.secondary) } Section("Web Content") { diff --git a/ChorusTests/ChorusTests.swift b/ChorusTests/ChorusTests.swift index 38930cb..cb04882 100644 --- a/ChorusTests/ChorusTests.swift +++ b/ChorusTests/ChorusTests.swift @@ -845,6 +845,22 @@ final class ChorusTests: XCTestCase { XCTAssertNotEqual(catalog.entry(for: "spotify")?.category, "Messaging") } + func testHideSpacesUIEffectiveDefaultsToShowing() { + // A legacy row (nil) must keep the rail visible, so upgrading doesn't + // silently hide part of the UI. + XCTAssertFalse(AppPreferences().hideSpacesUIEffective) + XCTAssertTrue(AppPreferences(hideSpacesUI: true).hideSpacesUIEffective) + XCTAssertFalse(AppPreferences(hideSpacesUI: false).hideSpacesUIEffective) + } + + func testToolbarPositionDefaultsToTopAndRejectsUnknownValues() { + XCTAssertEqual(AppPreferences().toolbarPosition, .top) + XCTAssertEqual(AppPreferences(toolbarPositionRaw: "bottom").toolbarPosition, .bottom) + XCTAssertEqual(AppPreferences(toolbarPositionRaw: "top").toolbarPosition, .top) + // An unreadable stored value falls back rather than trapping. + XCTAssertEqual(AppPreferences(toolbarPositionRaw: "sideways").toolbarPosition, .top) + } + // MARK: - Scheduled DND (quiet hours) @MainActor