Files
ProdDash/viewer/macos/Sources/SettingsWindowController.swift
T

300 lines
11 KiB
Swift
Raw Normal View History

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<Content: View>(_ 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
}
}