From e7a19a1940596e07fc9da7efe50b5ac6c08a523d Mon Sep 17 00:00:00 2001 From: Marcio Vinicius Spiridigliozzi da Silva Leite Date: Mon, 20 Jul 2026 09:23:56 -0300 Subject: [PATCH] feat: add opt-in spaces hiding and a bottom navigation bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two layout options, both defaulting to today's behaviour so an upgrade changes nothing until the user asks. Hide the spaces bar Some setups keep every service in one space, leaving the spaces rail as permanent dead weight. Adds a persisted `hideSpacesUI` preference with three ways to flip it: a Settings toggle, View > Hide Spaces Bar (Ctrl-Cmd-S), and a button in the services rail footer. That button lives in the SERVICES rail deliberately — the spaces rail is what disappears, so a control hosted there would remove itself and leave only the menu as a way back. Hiding is purely visual: the selected space is untouched, and Cmd-K, Ctrl-Tab and the menu bar still switch spaces. Services in a space you aren't viewing stay out of the sidebar as before, so the Settings caption points at Cmd-K. Navigation buttons at the bottom At the top they compete with the traffic lights and the badge cluster. `ToolbarPosition` (top/bottom) moves them to a bar of their own. At `.bottom` both the sidebar row and the tab-bar buttons are suppressed so they never render twice. Layout corrections that came with the above - The rails' vertical rules ran the full height and cut through the traffic-light strip; they now start below it. - `.topBars` and `.hybrid` inset their service tabs on the assumption that the spaces rail was there to clear the traffic lights. With it hidden they take the full inset instead. - The traffic-light metrics were repeated as literals in two files; extracted to `WindowChrome` so a rail and the view beside it can't drift apart. The window title from the earlier revision of this PR is dropped: it was a no-op on macOS 26 (the window's title property set, but nothing drew), and it duplicated the highlighted service icon anyway. The traffic-light strip is still reserved as empty space so the top nav row clears the window buttons, but nothing is drawn into it. New preferences are `Optional`, matching the surrounding pattern, so SwiftData lightweight migration is a no-op on existing stores. Two tests cover the preference resolution. Suite green (109). Verified by the maintainer on macOS 26 in the prior revision: hiding the spaces bar reflows correctly and reverses, all three layouts switch, and the window drag band still works in the horizontal layouts. Co-Authored-By: Claude Opus 4.8 --- Chorus/App/AppState.swift | 38 +++++++++++++ Chorus/App/ChorusApp.swift | 9 +++ Chorus/Models/AppPreferences.swift | 41 +++++++++++++- Chorus/Views/MainWindow/ContentView.swift | 55 +++++++++++++++---- .../Views/MainWindow/ServiceSidebarView.swift | 34 +++++++++++- Chorus/Views/MainWindow/WebContentView.swift | 46 +++++++++++++--- Chorus/Views/Settings/SettingsView.swift | 22 ++++++++ ChorusTests/ChorusTests.swift | 16 ++++++ 8 files changed, 239 insertions(+), 22 deletions(-) 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