|
| 1 | +import AppKit |
| 2 | +import HeadlessProtocol |
| 3 | + |
| 4 | +final class SettingsWindowController: NSWindowController, NSWindowDelegate, NSTextFieldDelegate { |
| 5 | + private static var sharedController: SettingsWindowController? |
| 6 | + |
| 7 | + static func orderFrontShared(_ sender: Any?) { |
| 8 | + if sharedController == nil { |
| 9 | + do { |
| 10 | + sharedController = try SettingsWindowController() |
| 11 | + } catch { |
| 12 | + presentOpenFailure(error) |
| 13 | + return |
| 14 | + } |
| 15 | + } |
| 16 | + sharedController?.showWindow(sender) |
| 17 | + sharedController?.window?.makeKeyAndOrderFront(sender) |
| 18 | + NSApp.activate(ignoringOtherApps: true) |
| 19 | + } |
| 20 | + |
| 21 | + private static func presentOpenFailure(_ error: Error) { |
| 22 | + let alert = NSAlert() |
| 23 | + alert.alertStyle = .critical |
| 24 | + alert.messageText = "Couldn’t open Settings" |
| 25 | + alert.informativeText = (error as? SettingsError)?.description ?? error.localizedDescription |
| 26 | + alert.addButton(withTitle: "OK") |
| 27 | + alert.runModal() |
| 28 | + } |
| 29 | + |
| 30 | + private let settings: SettingsController |
| 31 | + private let stack = NSStackView() |
| 32 | + private var isReloading = false |
| 33 | + private var firstEditor: NSView? |
| 34 | + |
| 35 | + private init() throws { |
| 36 | + settings = SettingsController(store: try SettingsStore.production()) |
| 37 | + let window = NSWindow( |
| 38 | + contentRect: NSRect(x: 0, y: 0, width: 520, height: 320), |
| 39 | + styleMask: [.titled, .closable, .miniaturizable, .resizable], |
| 40 | + backing: .buffered, |
| 41 | + defer: false |
| 42 | + ) |
| 43 | + window.title = "Settings" |
| 44 | + window.minSize = NSSize(width: 400, height: 240) |
| 45 | + window.isReleasedWhenClosed = false |
| 46 | + window.tabbingMode = .disallowed |
| 47 | + window.setFrameAutosaveName("HeadlessSettings") |
| 48 | + window.identifier = NSUserInterfaceItemIdentifier("headless-settings") |
| 49 | + window.autorecalculatesKeyViewLoop = true |
| 50 | + super.init(window: window) |
| 51 | + window.delegate = self |
| 52 | + buildContent() |
| 53 | + try reload() |
| 54 | + let fitted = max(stack.fittingSize.height + 8, 260) |
| 55 | + window.setContentSize(NSSize(width: 520, height: min(fitted, 640))) |
| 56 | + window.center() |
| 57 | + } |
| 58 | + |
| 59 | + required init?(coder: NSCoder) { fatalError("not used") } |
| 60 | + |
| 61 | + func windowDidBecomeKey(_ notification: Notification) { |
| 62 | + try? reload() |
| 63 | + } |
| 64 | + |
| 65 | + private func buildContent() { |
| 66 | + guard let content = window?.contentView else { return } |
| 67 | + stack.orientation = .vertical |
| 68 | + stack.alignment = .leading |
| 69 | + stack.spacing = 18 |
| 70 | + stack.edgeInsets = NSEdgeInsets(top: 20, left: 22, bottom: 20, right: 22) |
| 71 | + stack.translatesAutoresizingMaskIntoConstraints = false |
| 72 | + content.addSubview(stack) |
| 73 | + NSLayoutConstraint.activate([ |
| 74 | + stack.leadingAnchor.constraint(equalTo: content.leadingAnchor), |
| 75 | + stack.trailingAnchor.constraint(equalTo: content.trailingAnchor), |
| 76 | + stack.topAnchor.constraint(equalTo: content.topAnchor), |
| 77 | + stack.bottomAnchor.constraint(lessThanOrEqualTo: content.bottomAnchor), |
| 78 | + ]) |
| 79 | + } |
| 80 | + |
| 81 | + private func reload() throws { |
| 82 | + isReloading = true |
| 83 | + defer { isReloading = false } |
| 84 | + for view in stack.arrangedSubviews { |
| 85 | + stack.removeArrangedSubview(view) |
| 86 | + view.removeFromSuperview() |
| 87 | + } |
| 88 | + firstEditor = nil |
| 89 | + let snapshots = try settings.snapshots() |
| 90 | + if snapshots.isEmpty { |
| 91 | + stack.addArrangedSubview(wrappingLabel("No settings are registered.", bold: false)) |
| 92 | + return |
| 93 | + } |
| 94 | + for snapshot in snapshots { |
| 95 | + stack.addArrangedSubview(makeSection(snapshot)) |
| 96 | + } |
| 97 | + window?.initialFirstResponder = firstEditor |
| 98 | + window?.recalculateKeyViewLoop() |
| 99 | + } |
| 100 | + |
| 101 | + private func makeSection(_ snapshot: SettingSnapshot) -> NSView { |
| 102 | + let section = NSStackView() |
| 103 | + section.orientation = .vertical |
| 104 | + section.alignment = .leading |
| 105 | + section.spacing = 6 |
| 106 | + section.translatesAutoresizingMaskIntoConstraints = false |
| 107 | + section.setHuggingPriority(.defaultLow, for: .horizontal) |
| 108 | + |
| 109 | + let title = wrappingLabel(snapshot.definition.key, bold: true) |
| 110 | + title.setAccessibilityLabel(snapshot.definition.key) |
| 111 | + section.addArrangedSubview(title) |
| 112 | + |
| 113 | + let summary = wrappingLabel(snapshot.definition.summary, bold: false) |
| 114 | + summary.textColor = .secondaryLabelColor |
| 115 | + summary.setAccessibilityLabel("Summary") |
| 116 | + section.addArrangedSubview(summary) |
| 117 | + |
| 118 | + let editor = makeEditor(snapshot) |
| 119 | + if firstEditor == nil { firstEditor = editor } |
| 120 | + let reset = NSButton( |
| 121 | + title: "Reset to Default", |
| 122 | + target: self, |
| 123 | + action: #selector(resetClicked(_:)) |
| 124 | + ) |
| 125 | + reset.bezelStyle = .rounded |
| 126 | + reset.identifier = NSUserInterfaceItemIdentifier("reset.\(snapshot.definition.key)") |
| 127 | + reset.setAccessibilityLabel("Reset \(snapshot.definition.key) to default") |
| 128 | + reset.isEnabled = snapshot.configured && snapshot.supportedOnCurrentPlatform |
| 129 | + |
| 130 | + let valueCaption = NSTextField(labelWithString: "Value") |
| 131 | + valueCaption.setAccessibilityHidden(true) |
| 132 | + let valueRow = NSStackView(views: [valueCaption, editor, reset]) |
| 133 | + valueRow.orientation = .horizontal |
| 134 | + valueRow.alignment = .centerY |
| 135 | + valueRow.spacing = 8 |
| 136 | + section.addArrangedSubview(valueRow) |
| 137 | + |
| 138 | + section.addArrangedSubview(metaLabel("Default", snapshot.definition.defaultValue)) |
| 139 | + section.addArrangedSubview(metaLabel("Platform", snapshot.platformSummary)) |
| 140 | + section.addArrangedSubview(metaLabel("Takes effect", snapshot.definition.restartBehavior.rawValue)) |
| 141 | + section.addArrangedSubview( |
| 142 | + metaLabel("Agents may modify", snapshot.agentsMayModify ? "yes" : "no") |
| 143 | + ) |
| 144 | + |
| 145 | + if let window, let content = window.contentView { |
| 146 | + section.widthAnchor.constraint( |
| 147 | + equalTo: content.widthAnchor, constant: -44 |
| 148 | + ).isActive = true |
| 149 | + } |
| 150 | + return section |
| 151 | + } |
| 152 | + |
| 153 | + private func makeEditor(_ snapshot: SettingSnapshot) -> NSView { |
| 154 | + let key = snapshot.definition.key |
| 155 | + if let values = snapshot.selectableValues { |
| 156 | + let popup = NSPopUpButton(frame: .zero, pullsDown: false) |
| 157 | + popup.autoenablesItems = false |
| 158 | + popup.addItems(withTitles: values) |
| 159 | + popup.selectItem(withTitle: snapshot.value) |
| 160 | + popup.target = self |
| 161 | + popup.action = #selector(valueChanged(_:)) |
| 162 | + popup.identifier = NSUserInterfaceItemIdentifier(key) |
| 163 | + popup.setAccessibilityLabel(key) |
| 164 | + popup.setAccessibilityHelp(snapshot.definition.summary) |
| 165 | + popup.isEnabled = snapshot.supportedOnCurrentPlatform |
| 166 | + return popup |
| 167 | + } |
| 168 | + |
| 169 | + let field = NSTextField(string: snapshot.value) |
| 170 | + field.identifier = NSUserInterfaceItemIdentifier(key) |
| 171 | + field.delegate = self |
| 172 | + field.target = self |
| 173 | + field.action = #selector(textCommitted(_:)) |
| 174 | + field.setAccessibilityLabel(key) |
| 175 | + field.setAccessibilityHelp(snapshot.definition.summary) |
| 176 | + field.isEditable = snapshot.supportedOnCurrentPlatform |
| 177 | + field.isSelectable = true |
| 178 | + field.placeholderString = snapshot.definition.defaultValue |
| 179 | + field.translatesAutoresizingMaskIntoConstraints = false |
| 180 | + field.widthAnchor.constraint(greaterThanOrEqualToConstant: 160).isActive = true |
| 181 | + return field |
| 182 | + } |
| 183 | + |
| 184 | + private func wrappingLabel(_ text: String, bold: Bool) -> NSTextField { |
| 185 | + let label = NSTextField(wrappingLabelWithString: text) |
| 186 | + label.font = bold ? .boldSystemFont(ofSize: 13) : .systemFont(ofSize: 12) |
| 187 | + label.preferredMaxLayoutWidth = 476 |
| 188 | + label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) |
| 189 | + return label |
| 190 | + } |
| 191 | + |
| 192 | + private func metaLabel(_ name: String, _ value: String) -> NSTextField { |
| 193 | + let label = NSTextField(labelWithString: "\(name): \(value)") |
| 194 | + label.font = .systemFont(ofSize: 11) |
| 195 | + label.textColor = .secondaryLabelColor |
| 196 | + label.setAccessibilityLabel(name) |
| 197 | + label.setAccessibilityValue(value) |
| 198 | + return label |
| 199 | + } |
| 200 | + |
| 201 | + @objc private func valueChanged(_ sender: NSPopUpButton) { |
| 202 | + guard !isReloading, let key = sender.identifier?.rawValue else { return } |
| 203 | + commit(key, sender.titleOfSelectedItem ?? "") |
| 204 | + } |
| 205 | + |
| 206 | + @objc private func textCommitted(_ sender: NSTextField) { |
| 207 | + guard !isReloading, let key = sender.identifier?.rawValue else { return } |
| 208 | + commit(key, sender.stringValue) |
| 209 | + } |
| 210 | + |
| 211 | + func controlTextDidEndEditing(_ obj: Notification) { |
| 212 | + guard let field = obj.object as? NSTextField else { return } |
| 213 | + textCommitted(field) |
| 214 | + } |
| 215 | + |
| 216 | + @objc private func resetClicked(_ sender: NSButton) { |
| 217 | + guard let raw = sender.identifier?.rawValue, raw.hasPrefix("reset.") else { return } |
| 218 | + let key = String(raw.dropFirst("reset.".count)) |
| 219 | + do { |
| 220 | + _ = try settings.reset(key) |
| 221 | + try reload() |
| 222 | + } catch { |
| 223 | + present(error) |
| 224 | + try? reload() |
| 225 | + } |
| 226 | + } |
| 227 | + |
| 228 | + private func commit(_ key: String, _ rawValue: String) { |
| 229 | + do { |
| 230 | + let current = try settings.store.snapshot(key, caller: .user) |
| 231 | + guard current.value != rawValue || !current.configured else { return } |
| 232 | + _ = try settings.set(key, rawValue: rawValue) |
| 233 | + try reload() |
| 234 | + } catch { |
| 235 | + present(error) |
| 236 | + try? reload() |
| 237 | + } |
| 238 | + } |
| 239 | + |
| 240 | + private func present(_ error: Error) { |
| 241 | + let alert = NSAlert() |
| 242 | + alert.alertStyle = .warning |
| 243 | + alert.messageText = "Couldn’t update settings" |
| 244 | + alert.informativeText = (error as? SettingsError)?.description ?? error.localizedDescription |
| 245 | + alert.addButton(withTitle: "OK") |
| 246 | + if let window { |
| 247 | + alert.beginSheetModal(for: window) { _ in } |
| 248 | + } else { |
| 249 | + alert.runModal() |
| 250 | + } |
| 251 | + } |
| 252 | +} |
0 commit comments