300 lines
11 KiB
Swift
300 lines
11 KiB
Swift
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
|
|||
|
|
}
|
|||
|
|
}
|