Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions Chorus/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand Down
9 changes: 9 additions & 0 deletions Chorus/App/ChorusApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
41 changes: 40 additions & 1 deletion Chorus/Models/AppPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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).
Expand Down Expand Up @@ -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
}
}
55 changes: 44 additions & 11 deletions Chorus/Views/MainWindow/ContentView.swift
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
}
Expand Down
34 changes: 31 additions & 3 deletions Chorus/Views/MainWindow/ServiceSidebarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ struct ServiceSidebarView: View {

Divider()

spacesToggleButton
addServiceButton
}
.frame(width: 52)
Expand All @@ -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
Expand Down Expand Up @@ -318,6 +322,7 @@ struct ServiceSidebarView: View {
.id(link.service.id)
}
addServiceButton
spacesToggleButton
}
.padding(.leading, 8 + contentInset)
.padding(.trailing, 8)
Expand Down Expand Up @@ -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
Expand Down
46 changes: 39 additions & 7 deletions Chorus/Views/MainWindow/WebContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
22 changes: 22 additions & 0 deletions Chorus/Views/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
Loading