Files
downterm/Downterm/CommandNotch/Views/WorkspacesSettingsView.swift

292 lines
12 KiB
Swift

import SwiftUI
struct WorkspacesSettingsView: View {
@ObservedObject private var workspaceRegistry = WorkspaceRegistry.shared
@ObservedObject private var screenRegistry = ScreenRegistry.shared
@State private var selectedWorkspaceID: WorkspaceID?
@State private var renameDraft = ""
@State private var isDeleteAlertPresented = false
private var effectiveSelectedWorkspaceID: WorkspaceID? {
selectedWorkspaceID ?? workspaceRegistry.workspaceSummaries.first?.id
}
private var selectedSummary: WorkspaceSummary? {
guard let effectiveSelectedWorkspaceID else { return nil }
return workspaceRegistry.summary(for: effectiveSelectedWorkspaceID)
}
private var selectedController: WorkspaceController? {
guard let effectiveSelectedWorkspaceID else { return nil }
return workspaceRegistry.controller(for: effectiveSelectedWorkspaceID)
}
private var selectedAssignedScreenIDs: [ScreenID] {
guard let effectiveSelectedWorkspaceID else { return [] }
return screenRegistry.assignedScreenIDs(to: effectiveSelectedWorkspaceID)
}
private var connectedScreenSummaries: [ConnectedScreenSummary] {
screenRegistry.connectedScreenSummaries()
}
private var activeConnectedScreenSummary: ConnectedScreenSummary? {
connectedScreenSummaries.first(where: \.isActive)
}
private var deletionFallbackSummary: WorkspaceSummary? {
guard let effectiveSelectedWorkspaceID,
let fallbackID = workspaceRegistry.deletionFallbackWorkspaceID(
forDeleting: effectiveSelectedWorkspaceID,
preferredFallback: workspaceRegistry.defaultWorkspaceID
) else {
return nil
}
return workspaceRegistry.summary(for: fallbackID)
}
var body: some View {
HStack(spacing: 20) {
List(selection: $selectedWorkspaceID) {
ForEach(workspaceRegistry.workspaceSummaries) { summary in
VStack(alignment: .leading, spacing: 4) {
Text(summary.name)
.font(.headline)
Text(usageDescription(for: summary))
.font(.caption)
.foregroundStyle(.secondary)
}
.tag(summary.id)
.accessibilityIdentifier("settings.workspace.row.\(summary.id.uuidString)")
}
}
.accessibilityIdentifier("settings.workspaces.list")
.frame(minWidth: 220, idealWidth: 240, maxWidth: 260, maxHeight: .infinity)
if let summary = selectedSummary {
Form {
Section("Identity") {
TextField("Workspace name", text: $renameDraft)
.accessibilityIdentifier("settings.workspaces.name-field")
.onSubmit {
renameSelectedWorkspace()
}
OptionalHotkeyRecorderView(
label: "Jump Hotkey",
binding: workspaceHotkeyBinding(for: summary.id)
)
HStack {
Button("Save Name") {
renameSelectedWorkspace()
}
.accessibilityIdentifier("settings.workspaces.save-name")
Button("New Workspace") {
createWorkspace()
}
.accessibilityIdentifier("settings.workspaces.new")
}
Text("Workspace jump hotkeys are active when the notch is open and switch the current screen to this workspace.")
.font(.caption)
.foregroundStyle(.secondary)
}
Section("Usage") {
LabeledContent("Assigned screens") {
Text("\(selectedAssignedScreenIDs.count)")
.accessibilityIdentifier("settings.workspaces.assigned-count")
}
LabeledContent("Open tabs") {
Text("\(selectedController?.tabs.count ?? 0)")
}
if selectedAssignedScreenIDs.isEmpty {
Text("No screens are currently assigned to this workspace.")
.foregroundStyle(.secondary)
} else {
ForEach(selectedAssignedScreenIDs, id: \.self) { screenID in
LabeledContent("Screen") {
Text(screenID)
.font(.caption.monospaced())
}
}
}
}
Section("Shared Workspace Rules") {
Text(sharedWorkspaceDescription(for: selectedAssignedScreenIDs.count))
.foregroundStyle(.secondary)
}
Section("Connected Screens") {
if let activeScreen = activeConnectedScreenSummary {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(activeScreen.displayName)
Text(activeScreen.id)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
Spacer()
Button(activeScreen.assignedWorkspaceID == summary.id ? "Assigned Here" : "Assign Current Screen") {
screenRegistry.assignWorkspace(summary.id, to: activeScreen.id)
}
.accessibilityIdentifier("settings.workspaces.assign-current")
.disabled(activeScreen.assignedWorkspaceID == summary.id)
}
} else {
Text("No connected screens are currently available.")
.foregroundStyle(.secondary)
}
ForEach(connectedScreenSummaries.filter { !$0.isActive }) { screen in
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(screen.displayName)
Text(screen.id)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
}
Spacer()
Button(screen.assignedWorkspaceID == summary.id ? "Assigned Here" : "Assign Here") {
screenRegistry.assignWorkspace(summary.id, to: screen.id)
}
.accessibilityIdentifier("settings.workspaces.assign.\(screen.id)")
.disabled(screen.assignedWorkspaceID == summary.id)
}
}
}
Section("Danger Zone") {
Button("Delete Workspace", role: .destructive) {
isDeleteAlertPresented = true
}
.accessibilityIdentifier("settings.workspaces.delete")
.disabled(!workspaceRegistry.canDeleteWorkspace(id: summary.id))
if !workspaceRegistry.canDeleteWorkspace(id: summary.id) {
Text("At least one workspace must remain.")
.foregroundStyle(.secondary)
}
}
}
.formStyle(.grouped)
} else {
ContentUnavailableView(
"No Workspaces",
systemImage: "rectangle.3.group",
description: Text("Create a workspace to start grouping tabs across screens.")
)
}
}
.onAppear {
selectInitialWorkspaceIfNeeded()
}
.onChange(of: workspaceRegistry.workspaceSummaries) { _, _ in
synchronizeSelectionWithRegistry()
}
.onChange(of: selectedWorkspaceID) { _, _ in
renameDraft = selectedSummary?.name ?? ""
}
.alert("Delete Workspace", isPresented: $isDeleteAlertPresented) {
Button("Cancel", role: .cancel) {}
Button("Delete", role: .destructive) {
deleteSelectedWorkspace()
}
} message: {
if let summary = selectedSummary, let fallback = deletionFallbackSummary {
Text(
"Deleting \(summary.name) reassigns its screens to \(fallback.name) and closes the workspace."
)
} else {
Text("At least one workspace must remain.")
}
}
}
private func usageDescription(for summary: WorkspaceSummary) -> String {
let screenCount = screenRegistry.assignedScreenCount(to: summary.id)
let tabCount = workspaceRegistry.controller(for: summary.id)?.tabs.count ?? 0
return "\(screenCount) screen\(screenCount == 1 ? "" : "s") · \(tabCount) tab\(tabCount == 1 ? "" : "s")"
}
private func sharedWorkspaceDescription(for screenCount: Int) -> String {
if screenCount > 1 {
return "This workspace is shared across \(screenCount) screens. Tab changes stay in sync across each assigned screen."
}
if screenCount == 1 {
return "This workspace is assigned to one screen. You can assign additional screens to share the same tabs."
}
return "Unassigned workspaces keep their tabs and can be attached to any screen later."
}
private func selectInitialWorkspaceIfNeeded() {
if selectedWorkspaceID == nil {
selectedWorkspaceID = workspaceRegistry.workspaceSummaries.first?.id
}
renameDraft = selectedSummary?.name ?? ""
}
private func synchronizeSelectionWithRegistry() {
guard let selectedWorkspaceID else {
selectInitialWorkspaceIfNeeded()
return
}
if workspaceRegistry.summary(for: selectedWorkspaceID) == nil {
self.selectedWorkspaceID = workspaceRegistry.workspaceSummaries.first?.id
}
renameDraft = selectedSummary?.name ?? ""
}
private func renameSelectedWorkspace() {
guard let effectiveSelectedWorkspaceID else { return }
workspaceRegistry.renameWorkspace(id: effectiveSelectedWorkspaceID, to: renameDraft)
renameDraft = selectedSummary?.name ?? renameDraft
}
private func createWorkspace() {
let workspaceID = workspaceRegistry.createWorkspace()
selectedWorkspaceID = workspaceID
renameDraft = workspaceRegistry.summary(for: workspaceID)?.name ?? ""
}
private func workspaceHotkeyBinding(for workspaceID: WorkspaceID) -> Binding<HotkeyBinding?> {
Binding(
get: {
workspaceRegistry.summary(for: workspaceID)?.hotkey
},
set: { newValue in
workspaceRegistry.updateWorkspaceHotkey(id: workspaceID, to: newValue)
}
)
}
private func deleteSelectedWorkspace() {
guard let effectiveSelectedWorkspaceID,
let fallbackWorkspaceID = screenRegistry.deleteWorkspace(
effectiveSelectedWorkspaceID,
preferredFallback: workspaceRegistry.defaultWorkspaceID
) else {
return
}
self.selectedWorkspaceID = fallbackWorkspaceID
renameDraft = workspaceRegistry.summary(for: fallbackWorkspaceID)?.name ?? ""
}
}