import AppKit import SwiftUI /// Settings (⌘,): a standard toolbar-tabbed settings window with two panes, /// Connections and General. Every change applies as it's made. final class SettingsWindowController: NSWindowController { private var model: SettingsModel! private var tabs: NSTabViewController! convenience init() { let model = SettingsModel() let tabs = NSTabViewController() tabs.tabStyle = .toolbar tabs.addTabViewItem(Self.pane("Connections", symbol: "network", ConnectionsPane(store: .shared, model: model))) tabs.addTabViewItem(Self.pane("General", symbol: "gearshape", GeneralPane(settings: .shared))) let window = NSWindow(contentViewController: tabs) window.styleMask = [.titled, .closable] window.toolbarStyle = .preference window.isRestorable = false self.init(window: window) self.model = model self.tabs = tabs window.center() } private static func pane(_ label: String, symbol: String, _ content: Content) -> NSTabViewItem { let controller = NSHostingController(rootView: content) controller.sizingOptions = .preferredContentSize controller.title = label // the window takes its title from the pane let item = NSTabViewItem(viewController: controller) item.label = label item.image = NSImage(systemSymbolName: symbol, accessibilityDescription: label) return item } /// Show Settings; `addingConnection` opens it on the add-a-connection sheet. func show(addingConnection: Bool = false) { if addingConnection { tabs.selectedTabViewItemIndex = 0 model.pendingAdd = true } showWindow(nil) window?.makeKeyAndOrderFront(nil) } } final class SettingsModel: ObservableObject { /// Set to open the add sheet as soon as the Connections pane can. @Published var pendingAdd = false } // MARK: - Connections private struct ConnectionDraft: Identifiable { var id: UUID var name: String var address: String var isNew: Bool } private struct ConnectionsPane: View { @ObservedObject var store: ConnectionStore @ObservedObject var model: SettingsModel @State private var selection: UUID? @State private var editing: ConnectionDraft? var body: some View { VStack(alignment: .leading, spacing: 10) { Text("Each connection is a ProdDash installation on your network. With more than one, a tab bar at the top of the window switches between them — drag them into the order you want.") .font(.callout) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) List(selection: $selection) { ForEach(store.connections) { connection in VStack(alignment: .leading, spacing: 2) { Text(connection.displayName) Text(connection.displayAddress) .font(.caption) .foregroundStyle(.secondary) } .padding(.vertical, 3) .tag(connection.id) } .onMove { store.move(from: $0, to: $1) } } .listStyle(.bordered(alternatesRowBackgrounds: true)) .contextMenu(forSelectionType: UUID.self) { ids in Button("Edit…") { edit(ids.first) } .disabled(ids.count != 1) Button("Remove") { ids.forEach(store.remove) if let selected = selection, ids.contains(selected) { selection = nil } } } primaryAction: { ids in edit(ids.first) } .overlay { if store.connections.isEmpty { Text("No Connections").foregroundStyle(.tertiary) } } .frame(height: 210) HStack(spacing: 6) { Button(action: add) { Image(systemName: "plus").frame(width: 14) } .help("Add a connection") .accessibilityLabel("Add") Button(action: removeSelected) { Image(systemName: "minus").frame(width: 14) } .help("Remove the selected connection") .accessibilityLabel("Remove") .disabled(selection == nil) Spacer() Button("Edit…") { edit(selection) } .disabled(selection == nil) } } .padding(20) .frame(width: 520) .sheet(item: $editing) { draft in ConnectionEditor(draft: draft, onSave: save) } .onReceive(model.$pendingAdd) { pending in guard pending else { return } DispatchQueue.main.async { model.pendingAdd = false add() } } } private func add() { editing = ConnectionDraft(id: UUID(), name: "", address: "", isNew: true) } private func edit(_ id: UUID?) { guard let id, let connection = store.connections.first(where: { $0.id == id }) else { return } editing = ConnectionDraft(id: id, name: connection.name, address: connection.displayAddress, isNew: false) } private func removeSelected() { guard let id = selection else { return } store.remove(id) selection = nil } private func save(_ draft: ConnectionDraft) { guard let url = Address.normalize(draft.address) else { return } let connection = Connection(id: draft.id, name: draft.name.trimmingCharacters(in: .whitespaces), address: url.absoluteString) if draft.isNew { store.add(connection) } else { store.update(connection) } selection = connection.id } } /// The add / edit sheet. It asks the address whether ProdDash is there as /// you type, so a mistyped address shows up here rather than on the booth /// display — but saving never depends on it: the installation may simply be /// switched off right now. private struct ConnectionEditor: View { @State var draft: ConnectionDraft let onSave: (ConnectionDraft) -> Void @Environment(\.dismiss) private var dismiss private enum Check: Equatable { case idle, checking, answer(Probe.Answer) } @State private var check: Check = .idle private var url: URL? { Address.normalize(draft.address) } var body: some View { VStack(alignment: .leading, spacing: 18) { Text(draft.isNew ? "Add Connection" : "Edit Connection") .font(.headline) Form { TextField("Name:", text: $draft.name, prompt: Text(url?.host ?? "AB Booth")) TextField("Address:", text: $draft.address, prompt: Text("10.3.11.152:24500")) LabeledContent { status .font(.caption) .frame(maxWidth: .infinity, minHeight: 30, alignment: .topLeading) } label: { EmptyView() } } HStack { Spacer() Button("Cancel", role: .cancel) { dismiss() } .keyboardShortcut(.cancelAction) Button(draft.isNew ? "Add" : "Save") { onSave(draft) dismiss() } .keyboardShortcut(.defaultAction) .disabled(url == nil) } } .padding(20) .frame(width: 460) .task(id: draft.address) { guard let url else { return check = .idle } check = .checking try? await Task.sleep(for: .milliseconds(500)) // let the typing finish guard !Task.isCancelled else { return } let answer = await Probe.ask(url) guard !Task.isCancelled else { return } check = .answer(answer) } } @ViewBuilder private var status: some View { switch check { case .idle where url == nil: Text(draft.address.isEmpty ? "The address you open ProdDash at in a browser. Without a port, ProdDash’s \(String(Address.defaultPort)) is used." : "That isn’t an address.") .foregroundStyle(.secondary) case .idle, .checking: // idle with an address: the check is about to start HStack(spacing: 6) { ProgressView().controlSize(.mini) Text("Checking \(url.map(Address.display) ?? "")…").foregroundStyle(.secondary) } case .answer(.prodDash): Label("ProdDash is answering at \(url.map(Address.display) ?? "").", systemImage: "checkmark.circle.fill") .foregroundStyle(.green) case .answer(.somethingElse): Label("Something answers at \(url.map(Address.display) ?? "") — but it isn’t ProdDash.", systemImage: "exclamationmark.triangle.fill") .foregroundStyle(.orange) case .answer(.noAnswer(let why)): Label(why, systemImage: "exclamationmark.triangle.fill") .foregroundStyle(.orange) } } } // MARK: - General private struct GeneralPane: View { @ObservedObject var settings: ViewerSettings @State private var openAtLogin = LoginItem.isEnabled || LoginItem.needsApproval @State private var needsApproval = LoginItem.needsApproval @State private var loginProblem: String? var body: some View { Form { Section { Toggle("Open at login", isOn: Binding( get: { openAtLogin }, set: { wanted in loginProblem = LoginItem.set(wanted) needsApproval = LoginItem.needsApproval openAtLogin = LoginItem.isEnabled || needsApproval })) if needsApproval { HStack { Text("Waiting for your approval in System Settings.") .foregroundStyle(.secondary) Spacer() Button("Open Login Items…") { LoginItem.openSystemSettings() } } } if let loginProblem { Text(loginProblem).foregroundStyle(.red) } } Section { Toggle(isOn: $settings.keepDisplayAwake) { Text("Keep the display awake") Text("While a dashboard is on screen, the display doesn’t dim or sleep.") } } } .formStyle(.grouped) .scrollDisabled(true) .frame(width: 520, height: needsApproval || loginProblem != nil ? 200 : 152) .onAppear(perform: refreshLoginItem) // Approving it in System Settings happens elsewhere; catch up on return. .onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { _ in refreshLoginItem() } } private func refreshLoginItem() { needsApproval = LoginItem.needsApproval openAtLogin = LoginItem.isEnabled || needsApproval } }