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
13 changes: 13 additions & 0 deletions Chorus/App/ChorusApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,13 @@ struct ChorusApp: App {
get: { appState.selectedSpaceID },
set: { appState.selectedSpaceID = $0 }
),
railLayout: appState.railLayout,
getServicesForSpace: { spaceID in
servicesForSpace(spaceID)
},
getAllServiceDestinations: {
allServiceDestinations()
},
getSpaces: {
allSpaces()
}
Expand Down Expand Up @@ -182,6 +186,15 @@ struct ChorusApp: App {
}
}

@MainActor
private func allServiceDestinations() -> [ServiceShortcutDestination] {
allSpaces().flatMap { space in
appState.servicesForSpace(space.id).map { service in
ServiceShortcutDestination(spaceID: space.id, serviceID: service.id)
}
}
}

@MainActor
private func saveWindowState() {
// Single shared accessor so we never mint a second preferences row.
Expand Down
17 changes: 13 additions & 4 deletions Chorus/Models/AppPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ enum AppPresenceMode: String, Codable {

/// Where the rail sits relative to the web content.
///
/// Three arrangements: one rail down the left, one rail along the top, or the
/// spaces down the left with that space's services along the top. `hybrid` was
/// Four arrangements: one rail down the left, one rail along the top, the
/// spaces down the left with that space's services along the top, or every
/// space's services grouped in one compact rail down the left. `hybrid` was
/// retired when one rail replaced two and is back because the two-rail
/// arrangement is worth having; the raw value never changed, and the build that
/// retired it never shipped, so a store that still says `hybrid` lands back on
Expand All @@ -22,15 +23,23 @@ enum RailLayout: String, Codable, CaseIterable {
case topBars
/// Spaces down the left, the current space's services along the top.
case hybrid
/// Every service grouped by space in one compact rail down the left.
case allServices

var displayName: String {
switch self {
case .sidebar: return "Rail on the left"
case .topBars: return "Bar along the top"
case .hybrid: return "Spaces on the left, services on top"
case .allServices: return "All services on the left"
}
}

/// Whether this layout puts navigation controls inside a top bar.
var hasTopBar: Bool {
self == .topBars || self == .hybrid
}

/// Reads a stored raw value, falling back to the default for anything
/// unrecognized.
static func resolving(_ raw: String?) -> RailLayout {
Expand Down Expand Up @@ -185,8 +194,8 @@ final class AppPreferences {
/// Materialises the storage-optional default zoom (nil → 1.0).
var defaultZoomEffective: Double { defaultZoom ?? 1.0 }

/// Resolves the stored rail layout. `hybrid` maps onto `.topBars`; anything
/// else unknown falls back to `.sidebar`. See `RailLayout.resolving(_:)`.
/// Resolves the stored rail layout. Unknown values fall back to `.sidebar`.
/// See `RailLayout.resolving(_:)`.
var railLayout: RailLayout {
RailLayout.resolving(railLayoutRaw)
}
Expand Down
60 changes: 60 additions & 0 deletions Chorus/Services/KeyboardShortcutManager.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,44 @@
import SwiftUI

struct ServiceShortcutDestination: Equatable {
let spaceID: UUID
let serviceID: UUID
}

enum ServiceShortcutNavigation {
static func destination(
at index: Int,
in destinations: [ServiceShortcutDestination]
) -> ServiceShortcutDestination? {
guard destinations.indices.contains(index) else { return nil }
return destinations[index]
}

static func destination(
movingBy offset: Int,
fromSpaceID spaceID: UUID?,
serviceID: UUID?,
in destinations: [ServiceShortcutDestination]
) -> ServiceShortcutDestination? {
guard !destinations.isEmpty else { return nil }
guard let currentIndex = destinations.firstIndex(where: {
$0.spaceID == spaceID && $0.serviceID == serviceID
}) else {
return offset < 0 ? destinations.last : destinations.first
}

let newIndex = (currentIndex + offset % destinations.count + destinations.count)
% destinations.count
return destinations[newIndex]
}
}

struct KeyboardShortcutCommands: Commands {
@Binding var selectedServiceID: UUID?
@Binding var selectedSpaceID: UUID?
let railLayout: RailLayout
let getServicesForSpace: (UUID) -> [ServiceInstance]
let getAllServiceDestinations: () -> [ServiceShortcutDestination]
let getSpaces: () -> [Space]

var body: some Commands {
Expand Down Expand Up @@ -44,13 +79,33 @@ struct KeyboardShortcutCommands: Commands {
}

private func switchToService(at index: Int) {
if railLayout == .allServices {
guard let destination = ServiceShortcutNavigation.destination(
at: index,
in: getAllServiceDestinations()
) else { return }
select(destination)
return
}

guard let spaceID = selectedSpaceID else { return }
let services = getServicesForSpace(spaceID)
guard index < services.count else { return }
selectedServiceID = services[index].id
}

private func switchServiceOffset(_ offset: Int) {
if railLayout == .allServices {
guard let destination = ServiceShortcutNavigation.destination(
movingBy: offset,
fromSpaceID: selectedSpaceID,
serviceID: selectedServiceID,
in: getAllServiceDestinations()
) else { return }
select(destination)
return
}

guard let spaceID = selectedSpaceID else { return }
let services = getServicesForSpace(spaceID)
guard !services.isEmpty else { return }
Expand All @@ -68,4 +123,9 @@ struct KeyboardShortcutCommands: Commands {
let newIndex = (currentIndex + offset + spaces.count) % spaces.count
selectedSpaceID = spaces[newIndex].id
}

private func select(_ destination: ServiceShortcutDestination) {
selectedSpaceID = destination.spaceID
selectedServiceID = destination.serviceID
}
}
34 changes: 24 additions & 10 deletions Chorus/Views/MainWindow/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ struct ContentView: View {
// would otherwise move the window instead of reordering) and let the
// WindowDragHandles move the window instead. The sidebar keeps the
// normal title-bar drag.
.background(WindowMovableConfigurator(isMovable: appState.railLayout == .sidebar))
.background(WindowMovableConfigurator(isMovable: !appState.railLayout.hasTopBar))
// Ask for macOS notification permission here, not in AppState.init:
// requesting during App.init (before the scene exists) can fail with
// "Notifications are not allowed for this application" and leave the app
Expand Down Expand Up @@ -227,13 +227,12 @@ struct ContentView: View {
}
}

/// Arranges the rails and the web content per the chosen layout: one rail
/// down the left, one along the top as a bar of tabs, or the spaces down the
/// left with that space's services along the top.
/// Arranges the rails and web content for the chosen layout, including the
/// compact left rail that groups every service by space.
///
/// The first two draw one rail, with the current space as its header. The
/// third is the only one that still puts two rails on screen, and there the
/// header comes off — the strip beside it is already saying where you are.
/// The ordinary left and top rails show the current space as their header.
/// The hybrid and all-services layouts already show spaces in the rail, so
/// they omit that header.
@ViewBuilder
private func mainLayout(
spaceSelection: Binding<UUID?>,
Expand All @@ -251,6 +250,19 @@ struct ContentView: View {
Divider()
webContent
}
case .allServices:
HStack(spacing: 0) {
rail(
axis: .vertical,
spaceSelection: spaceSelection,
serviceSelection: serviceSelection,
contentInset: lightsHeight,
showsSpaceHeader: false,
showsAllSpaces: true
)
Divider()
webContent
}
case .topBars:
VStack(spacing: 0) {
rail(axis: .horizontal, spaceSelection: spaceSelection, serviceSelection: serviceSelection, contentInset: lightsWidth)
Expand Down Expand Up @@ -290,14 +302,16 @@ struct ContentView: View {
spaceSelection: Binding<UUID?>,
serviceSelection: Binding<UUID?>,
contentInset: CGFloat = 0,
showsSpaceHeader: Bool = true
showsSpaceHeader: Bool = true,
showsAllSpaces: Bool = false
) -> some View {
UnifiedRailView(
selectedSpaceID: spaceSelection,
selectedServiceID: serviceSelection,
axis: axis,
contentInset: contentInset,
showsSpaceHeader: showsSpaceHeader
showsSpaceHeader: showsSpaceHeader,
showsAllSpaces: showsAllSpaces
)
.accessibilityElement(children: .contain)
.accessibilityLabel("Space and services")
Expand All @@ -318,7 +332,7 @@ struct ContentView: View {
private var supportButtonTopInset: CGFloat {
let overhang = SupportButtonMetrics.targetOverhang
switch appState.railLayout {
case .sidebar: return 6 - overhang
case .sidebar, .allServices: return 6 - overhang
// The hybrid layout puts the same 42 point bar along the top, so the
// button is centred in it the same way.
case .topBars, .hybrid: return (UnifiedRailView.barHeight - SupportButtonMetrics.chipSize) / 2 - overhang
Expand Down
26 changes: 26 additions & 0 deletions Chorus/Views/MainWindow/RailSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,32 @@ enum ServiceReorder {
}
}

/// Places a service membership in an ordered target group. Unlike
/// `ServiceReorder`, the moving id may come from another group and therefore
/// may not be present in `targetIDs` yet.
enum ServicePlacement {
static func orderedIDs(
_ targetIDs: [UUID],
moving droppedID: UUID,
relativeTo targetID: UUID?,
placement: ServiceReorderPlacement
) -> [UUID]? {
guard targetID != droppedID else { return nil }

var reordered = targetIDs.filter { $0 != droppedID }
let insertionIndex: Int
if let targetID {
guard let targetIndex = reordered.firstIndex(of: targetID) else { return nil }
insertionIndex = placement == .after ? targetIndex + 1 : targetIndex
} else {
insertionIndex = 0
}

reordered.insert(droppedID, at: insertionIndex)
return reordered == targetIDs ? nil : reordered
}
}

/// Whether the rail draws service names, and where that answer is kept.
///
/// This is chrome visibility rather than user data, so it lives in defaults
Expand Down
7 changes: 6 additions & 1 deletion Chorus/Views/MainWindow/ServiceRowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ struct ServiceRowView: View {
/// the room. Nothing becomes unreachable: the name is still in the tooltip
/// and the spoken label, and so is every accessory the compact cell drops.
var showsName: Bool = true
/// Optional group context for layouts that show the same service in more
/// than one space. Compact tooltips and VoiceOver include it.
var spaceName: String? = nil
/// Whether the keyboard is on this row. Drawn as a ring, never as the fill
/// selection uses — see `RowMark`.
var isFocused: Bool = false
Expand Down Expand Up @@ -94,7 +97,7 @@ struct ServiceRowView: View {

/// What VoiceOver reads, and what the compact cell's tooltip borrows.
private var spokenLabel: String {
ServiceAccessibility.label(
let label = ServiceAccessibility.label(
name: instance.label,
badgeCount: badgeCount,
isHibernated: isHibernated,
Expand All @@ -104,6 +107,8 @@ struct ServiceRowView: View {
micMuted: micMuted,
health: health
)
guard let spaceName else { return label }
return "\(label), \(spaceName)"
}

@ViewBuilder
Expand Down
Loading