diff --git a/README.md b/README.md index 1e57153..607bb73 100644 --- a/README.md +++ b/README.md @@ -133,8 +133,8 @@ meeting prompt — the list behind the "Ignore " button on the prompt and t "Ignore" button on the auto-record banner. Clicking it adds the app here, ends any recording that button started, and yap says nothing about that app again. Manage the list under Meetings in the Settings window: it shows each app by icon -and name, removes one with the minus button, and "Add App…" excludes an app -ahead of time. Detection stays fail-open — an app you have never excluded still +and name, and the + and − under it add one ahead of time or stop ignoring the +one you select. Detection stays fail-open — an app you have never excluded still gets offered, even one yap has never heard of. `mic_voice_processing` cancels speaker echo on the mic track. On by default: a diff --git a/Sources/yap/UI/SettingsModel.swift b/Sources/yap/UI/SettingsModel.swift new file mode 100644 index 0000000..e9fcb5a --- /dev/null +++ b/Sources/yap/UI/SettingsModel.swift @@ -0,0 +1,203 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +// MARK: - Model + +/// Snapshot of the config file, with every setter writing straight back. +/// +/// No Apply button: a toggle here is the same act as saving the file, and the +/// daemon reloads either way. It deliberately does not watch the file while +/// open — a fresh snapshot per open is enough, and the loser of a race with a +/// text editor is whichever wrote first. +@MainActor +final class SettingsModel: ObservableObject { + struct ExcludedApp: Identifiable { + /// The bundle identifier, which is what the config file stores. + let id: String + let name: String + /// Nil when the app is no longer installed; the row draws its own + /// placeholder rather than the generic application icon. + let icon: NSImage? + /// Whether the app is still on this Mac. + let installed: Bool + } + + @Published var hotkey: String { didSet { writeDictation("hotkey", hotkey) } } + @Published var tapToToggle: Bool { didSet { writeDictation("tap_to_toggle", tapToToggle) } } + @Published var overlay: Bool { didSet { writeDictation("overlay", overlay) } } + @Published var newlineAfterRelease: Bool { + didSet { writeDictation("newline_after_release", newlineAfterRelease) } + } + @Published var muteOutput: Bool { didSet { writeDictation("mute_output", muteOutput) } } + @Published var model: String { didSet { writeDictation("model", model) } } + + @Published var recordingsDir: String { didSet { write("recordings_dir", recordingsDir) } } + @Published var transcriptionEnabled: Bool { + didSet { writeTranscription("enabled", transcriptionEnabled) } + } + @Published var micVoiceProcessing: Bool { + didSet { write("mic_voice_processing", micVoiceProcessing) } + } + /// Debounced, unlike every other control: a keystroke is not a decision, + /// and writing per character would rewrite the file — and wake the + /// watcher — a dozen times while someone types a command. + @Published var onStop: String { didSet { scheduleOnStopWrite() } } + + @Published var meetingDetection: Bool { didSet { write("meeting_detection", meetingDetection) } } + @Published var meetingAutoRecord: Bool { + didSet { write("meeting_auto_record", meetingAutoRecord) } + } + @Published private(set) var excludedApps: [ExcludedApp] + + /// Suppresses the write-through while `init` fills the properties in. + private var loading = true + private var onStopWrite: Task? + + init() { + hotkey = HotkeyMonitor.Key(name: Config.hotkey() ?? "")?.rawValue + ?? HotkeyMonitor.Key.fn.rawValue + tapToToggle = Config.tapToToggle() + overlay = Config.overlayEnabled() + newlineAfterRelease = Config.newlineAfterRelease() + muteOutput = Config.muteOutputWhileDictating() + model = Config.dictationModel().flatMap { ModelRegistry.find($0)?.id } + ?? ModelRegistry.recommended()?.id ?? "" + recordingsDir = Self.abbreviated(Config.recordingsDir() ?? Config.defaultRoot) + transcriptionEnabled = Config.transcriptionEnabled() + micVoiceProcessing = Config.micVoiceProcessing() + onStop = Config.onStop() ?? "" + meetingDetection = Config.meetingDetectionEnabled() + meetingAutoRecord = Config.meetingAutoRecord() + excludedApps = Config.meetingExcludedApps().map(Self.resolve) + loading = false + } + + // MARK: actions + + func chooseRecordingsDir() { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + panel.prompt = "Choose" + panel.directoryURL = Config.recordingsDir() ?? Config.defaultRoot + guard panel.runModal() == .OK, let url = panel.url else { return } + recordingsDir = Self.abbreviated(url) + } + + func addExcludedApp() { + let panel = NSOpenPanel() + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.allowsMultipleSelection = false + panel.allowedContentTypes = [.applicationBundle] + panel.directoryURL = URL(fileURLWithPath: "/Applications", isDirectory: true) + panel.prompt = "Ignore" + guard panel.runModal() == .OK, let url = panel.url else { return } + // An app with no identifier in its Info.plist has nothing we could + // store, and nothing to match a capture pid against later. + guard let bundleID = Bundle(url: url)?.bundleIdentifier else { return } + guard !excludedApps.contains(where: { $0.id == bundleID }) else { return } + excludedApps.append(Self.resolve(bundleID)) + writeExcludedApps() + } + + func removeExcludedApp(_ bundleID: String) { + excludedApps.removeAll { $0.id == bundleID } + writeExcludedApps() + } + + func openConfigFile() { + Config.ensureFileExists() + // Before it opens, not after: a config written by an earlier yap has + // no line for the settings added since, and this is the moment someone + // is looking for them. + Config.ensureEveryKeyPresent() + NSWorkspace.shared.open(Config.path) + } + + static func label(for key: HotkeyMonitor.Key) -> String { + switch key { + case .fn: return "Fn (Globe)" + case .rightOption: return "Right Option ⌥" + case .rightCommand: return "Right Command ⌘" + case .rightControl: return "Right Control ⌃" + case .rightShift: return "Right Shift ⇧" + } + } + + // MARK: writing + + private func write(_ key: String, _ value: Any?) { + guard !loading else { return } + Config.update { config in + if let value { + config[key] = value + } else { + config.removeValue(forKey: key) + } + } + } + + private func writeDictation(_ key: String, _ value: Any) { + writeSection("dictation", key, value) + } + + private func writeTranscription(_ key: String, _ value: Any) { + writeSection("transcription", key, value) + } + + /// A nested key, creating the object if the file has never had one. + private func writeSection(_ section: String, _ key: String, _ value: Any) { + guard !loading else { return } + Config.update { config in + var object = config[section] as? [String: Any] ?? [:] + object[key] = value + config[section] = object + } + } + + private func writeExcludedApps() { + write("meeting_excluded_apps", excludedApps.map(\.id)) + } + + private func scheduleOnStopWrite() { + guard !loading else { return } + onStopWrite?.cancel() + let command = onStop.trimmingCharacters(in: .whitespacesAndNewlines) + onStopWrite = Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(600)) + guard !Task.isCancelled else { return } + // An empty hook is no hook: drop the key rather than leave an + // empty string for `Config.onStop()` to filter out forever. + self?.write("on_stop", command.isEmpty ? nil : command) + } + } + + // MARK: lookups + + private static func abbreviated(_ url: URL) -> String { + (url.path as NSString).abbreviatingWithTildeInPath + } + + /// Bundle id back to something a person recognises. An app that has since + /// been deleted keeps its place in the list under its raw identifier — + /// removing an exclusion the user cannot see is not ours to decide. + private static func resolve(_ bundleID: String) -> ExcludedApp { + guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) else { + return ExcludedApp( + id: bundleID, + name: bundleID, + icon: nil, + installed: false + ) + } + return ExcludedApp( + id: bundleID, + name: FileManager.default.displayName(atPath: url.path), + icon: NSWorkspace.shared.icon(forFile: url.path), + installed: true + ) + } +} diff --git a/Sources/yap/UI/SettingsPanes.swift b/Sources/yap/UI/SettingsPanes.swift new file mode 100644 index 0000000..98739b7 --- /dev/null +++ b/Sources/yap/UI/SettingsPanes.swift @@ -0,0 +1,252 @@ +import AppKit +import SwiftUI + +// MARK: - Panes + +struct DictationPane: View { + @ObservedObject var model: SettingsModel + + var body: some View { + Form { + Section { + Picker("Hotkey", selection: $model.hotkey) { + ForEach(HotkeyMonitor.Key.allCases, id: \.self) { key in + Text(SettingsModel.label(for: key)).tag(key.rawValue) + } + } + Toggle("Tap to toggle", isOn: $model.tapToToggle) + Toggle("Show recording pill", isOn: $model.overlay) + Toggle("Press Return after dictating", isOn: $model.newlineAfterRelease) + Toggle("Mute speakers while dictating", isOn: $model.muteOutput) + } + Section { + Picker("Model", selection: $model.model) { + ForEach(ModelRegistry.shared, id: \.id) { entry in + Text("\(entry.displayName) · \(entry.sizeMB) MB").tag(entry.id) + } + } + } footer: { + RestartNote() + } + } + .formStyle(.grouped) + } +} + +struct RecordingPane: View { + @ObservedObject var model: SettingsModel + + var body: some View { + Form { + Section { + LabeledContent("Folder") { + HStack(spacing: 8) { + Text(model.recordingsDir) + .lineLimit(1) + .truncationMode(.head) + .foregroundStyle(.secondary) + Button("Choose…") { model.chooseRecordingsDir() } + } + } + } footer: { + RestartNote() + } + Section { + Toggle("Transcribe recordings automatically", isOn: $model.transcriptionEnabled) + Toggle("Voice processing on the mic", isOn: $model.micVoiceProcessing) + } + Section { + LabeledContent("Run after each recording") { + TextField("shell command", text: $model.onStop) + .font(.system(size: 12, design: .monospaced)) + .textFieldStyle(.roundedBorder) + } + } footer: { + Text("Given the session folder as its argument.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } + .formStyle(.grouped) + } +} + +struct MeetingsPane: View { + @ObservedObject var model: SettingsModel + @State private var selection: String? + + var body: some View { + Form { + Section { + Toggle("Detect meetings", isOn: $model.meetingDetection) + Toggle("Record without asking", isOn: $model.meetingAutoRecord) + .disabled(!model.meetingDetection) + } + Section("Ignored apps") { + IgnoredAppList( + apps: model.excludedApps, + selection: $selection, + onAdd: { model.addExcludedApp() }, + onRemove: { + guard let selection else { return } + model.removeExcludedApp(selection) + self.selection = nil + } + ) + .disabled(!model.meetingDetection) + } + } + .formStyle(.grouped) + } +} + +/// Settings that only the next launch reads. Said once, next to the control it +/// applies to, rather than as a line floating under a whole section. +private struct RestartNote: View { + var body: some View { + Label("Applies after yap restarts.", systemImage: "arrow.clockwise") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } +} + +// MARK: - Ignored apps + +/// The bordered list with `+` and `−` under it that macOS uses everywhere an +/// editable set of things lives. Familiar beats invented here: anyone who has +/// added a login item already knows how to work this. +private struct IgnoredAppList: View { + let apps: [SettingsModel.ExcludedApp] + @Binding var selection: String? + let onAdd: () -> Void + let onRemove: () -> Void + + var body: some View { + VStack(spacing: 0) { + ScrollView { + LazyVStack(spacing: 0) { + // Between rows, never after the last: a hairline floating + // in the empty space under the final row reads as content + // clipped off the bottom. + ForEach(Array(apps.enumerated()), id: \.element.id) { index, app in + if index > 0 { + Divider().padding(.leading, 35) + } + row(app) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .overlay { + if apps.isEmpty { + Text("Apps you ignore never trigger a meeting prompt.") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 16) + } + } + + Divider() + HStack(spacing: 0) { + stepper("plus", help: "Ignore an app…", action: onAdd) + Divider().frame(height: 16) + stepper("minus", help: "Stop ignoring the selected app", action: onRemove) + .disabled(selection == nil) + Spacer(minLength: 0) + } + .frame(height: 24) + .background(.quaternary.opacity(0.35)) + } + // Four rows before it scrolls; three fit without clipping the second + // line of the last one, which 138 did. + .frame(height: 168) + .background(Color(nsColor: .textBackgroundColor)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay { + RoundedRectangle(cornerRadius: 6) + .strokeBorder(Color(nsColor: .separatorColor)) + } + } + + private func row(_ app: SettingsModel.ExcludedApp) -> some View { + let selected = selection == app.id + return HStack(spacing: 8) { + icon(app) + .frame(width: 18, height: 18) + VStack(alignment: .leading, spacing: 1) { + // An uninstalled app has no name to show, so the identifier + // moves up and becomes the row. Printing it twice — once as a + // stand-in name and once as the subtitle — read as a debug + // dump rather than a list of apps. + Text(app.installed ? app.name : app.id) + .font(.system(size: 12)) + .lineLimit(1) + .truncationMode(.middle) + Text(app.installed ? app.id : "Not installed") + .font(.system(size: 10)) + .foregroundStyle( + selected ? AnyShapeStyle(.white.opacity(0.75)) : AnyShapeStyle(.secondary)) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer(minLength: 0) + } + .foregroundStyle(selected ? AnyShapeStyle(.white) : AnyShapeStyle(.primary)) + .padding(.horizontal, 9) + .padding(.vertical, 7) + .frame(maxWidth: .infinity, alignment: .leading) + .background(selected ? Color.accentColor : .clear) + .contentShape(Rectangle()) + .onTapGesture { selection = selected ? nil : app.id } + } + + /// A drawn placeholder rather than the generic application icon: a blank + /// squircle beside two real app icons reads as a failed image load, and + /// the row is trying to say the app is gone. + @ViewBuilder + private func icon(_ app: SettingsModel.ExcludedApp) -> some View { + if let image = app.icon { + Image(nsImage: image).resizable() + } else { + Image(systemName: "questionmark.app.dashed") + .font(.system(size: 15)) + .foregroundStyle(selection == app.id ? AnyShapeStyle(.white.opacity(0.8)) + : AnyShapeStyle(.secondary)) + } + } + + private func stepper( + _ symbol: String, help: String, action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Image(systemName: symbol) + .font(.system(size: 11, weight: .semibold)) + .frame(width: 30, height: 24) + .contentShape(Rectangle()) + } + .buttonStyle(.borderless) + .help(help) + } +} + +// MARK: - Material + +/// The sidebar's own material. `List(.sidebar)` draws its selection and +/// spacing correctly in a plain window but not its translucency, and a flat +/// gray rail beside a vibrant one is the tell that a window was assembled. +struct SidebarMaterial: NSViewRepresentable { + let material: NSVisualEffectView.Material + + func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.material = material + view.blendingMode = .behindWindow + view.state = .followsWindowActiveState + return view + } + + func updateNSView(_ view: NSVisualEffectView, context: Context) { + view.material = material + } +} diff --git a/Sources/yap/UI/SettingsWindow.swift b/Sources/yap/UI/SettingsWindow.swift index bda0c24..5c5322a 100644 --- a/Sources/yap/UI/SettingsWindow.swift +++ b/Sources/yap/UI/SettingsWindow.swift @@ -11,13 +11,19 @@ import UniformTypeIdentifiers /// would rather type. Nothing here is created until the menu item is clicked — /// an app that spends most of its life idle does not build a settings window /// at launch. +/// +/// Laid out the way macOS lays settings out: a source list on the left, one +/// short pane on the right. Three panes that each fit on screen beat one +/// column you scroll, and the rail doubles as the map — you can see everything +/// yap can be told to do without touching anything. @MainActor enum SettingsWindow { private static var window: NSWindow? - /// Wide enough for a grouped form's labels and controls without the two - /// columns colliding, narrow enough to stay a settings sheet. - private static let width: CGFloat = 440 + /// Rail plus pane, sized to the tallest pane — Meetings, whose app list + /// is the only thing here that grows. The shorter panes carry a little + /// slack, which beats one pane scrolling on open. + private static let contentSize = NSSize(width: 660, height: 420) static func show() { let window = self.window ?? make() @@ -27,10 +33,8 @@ enum SettingsWindow { // The window owns its size, not the form. Both halves of that matter: // `sizingOptions = []` stops the controller pushing a preferred size, // and restoring the size around the swap stops AppKit collapsing the - // window onto the form's *minimum* height — the form is taller than - // any laptop screen and declares no ideal height of its own, so - // without this it reopens at 320 pt with everything below Dictation - // out of sight, and a size the user chose is thrown away too. + // window onto the content's *minimum* height, which would also throw + // away a size the user chose. controller.sizingOptions = [] let size = window.contentRect(forFrameRect: window.frame).size window.contentViewController = controller @@ -43,13 +47,8 @@ enum SettingsWindow { } private static func make() -> NSWindow { - // As tall as the screen comfortably allows. The whole form does not fit - // on any laptop display — it scrolls — but at this height the Meetings - // section, which is the one people open this window to reach, is on - // screen without a scroll. - let available = (NSScreen.main?.visibleFrame.height ?? 900) - 60 let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: width, height: min(880, available)), + contentRect: NSRect(origin: .zero, size: contentSize), styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false @@ -58,299 +57,138 @@ enum SettingsWindow { // NSWindow defaults to release-on-close, which over-releases under ARC // — and we keep this one around between opens. window.isReleasedWhenClosed = false - window.contentMinSize = NSSize(width: width, height: 320) + window.contentMinSize = NSSize(width: 620, height: 380) window.center() self.window = window return window } } -// MARK: - View - -private struct SettingsView: View { - @StateObject private var model = SettingsModel() - - var body: some View { - Form { - Section("Dictation") { - Picker("Hotkey", selection: $model.hotkey) { - ForEach(HotkeyMonitor.Key.allCases, id: \.self) { key in - Text(SettingsModel.label(for: key)).tag(key.rawValue) - } - } - Toggle("Tap to toggle", isOn: $model.tapToToggle) - Toggle("Show recording pill", isOn: $model.overlay) - Toggle("Press Return after dictating", isOn: $model.newlineAfterRelease) - Toggle("Mute speakers while dictating", isOn: $model.muteOutput) - Picker("Model", selection: $model.model) { - ForEach(ModelRegistry.shared, id: \.id) { entry in - Text("\(entry.displayName) · \(entry.sizeMB) MB").tag(entry.id) - } - } - Text("Applies after yap restarts.") - .font(.caption) - .foregroundStyle(.secondary) - } - - Section("Recording") { - LabeledContent("Folder") { - HStack(spacing: 8) { - Text(model.recordingsDir) - .lineLimit(1) - .truncationMode(.head) - .foregroundStyle(.secondary) - Button("Choose…") { model.chooseRecordingsDir() } - } - } - Text("Applies after yap restarts.") - .font(.caption) - .foregroundStyle(.secondary) - Toggle("Transcribe recordings automatically", isOn: $model.transcriptionEnabled) - Toggle("Voice processing on the mic", isOn: $model.micVoiceProcessing) - LabeledContent("Run after each recording") { - TextField("shell command, given the session folder", text: $model.onStop) - .font(.system(.body, design: .monospaced)) - .textFieldStyle(.roundedBorder) - } - } +// MARK: - Panes - Section("Meetings") { - Toggle("Detect meetings", isOn: $model.meetingDetection) - Toggle("Record without asking", isOn: $model.meetingAutoRecord) - .disabled(!model.meetingDetection) +/// The three things yap can be told about, in the order you meet them: the +/// key you hold, what happens to a recording, and when to offer one. +private enum SettingsPane: String, CaseIterable, Identifiable { + case dictation + case recording + case meetings - LabeledContent("Ignored apps") { - VStack(alignment: .leading, spacing: 6) { - if model.excludedApps.isEmpty { - Text("Apps you ignore never trigger a meeting prompt.") - .font(.caption) - .foregroundStyle(.secondary) - } else { - ForEach(model.excludedApps) { app in - HStack(spacing: 6) { - Image(nsImage: app.icon) - .resizable() - .frame(width: 18, height: 18) - Text(app.name) - .lineLimit(1) - .truncationMode(.middle) - Spacer(minLength: 8) - Button { - model.removeExcludedApp(app.id) - } label: { - Image(systemName: "minus.circle") - } - .buttonStyle(.borderless) - .help("Stop ignoring \(app.name)") - } - } - } - Button("Add App…") { model.addExcludedApp() } - } - } - } + var id: Self { self } - Section { - Button("Open Config File") { model.openConfigFile() } - } + var title: String { + switch self { + case .dictation: return "Dictation" + case .recording: return "Recording" + case .meetings: return "Meetings" } - .formStyle(.grouped) - // Fills whatever the window is; the window owns the size, and the - // grouped form scrolls when the content outgrows it. - .frame(maxWidth: .infinity, maxHeight: .infinity) - } -} - -// MARK: - Model - -/// Snapshot of the config file, with every setter writing straight back. -/// -/// No Apply button: a toggle here is the same act as saving the file, and the -/// daemon reloads either way. It deliberately does not watch the file while -/// open — a fresh snapshot per open is enough, and the loser of a race with a -/// text editor is whichever wrote first. -@MainActor -private final class SettingsModel: ObservableObject { - struct ExcludedApp: Identifiable { - /// The bundle identifier, which is what the config file stores. - let id: String - let name: String - let icon: NSImage - } - - @Published var hotkey: String { didSet { writeDictation("hotkey", hotkey) } } - @Published var tapToToggle: Bool { didSet { writeDictation("tap_to_toggle", tapToToggle) } } - @Published var overlay: Bool { didSet { writeDictation("overlay", overlay) } } - @Published var newlineAfterRelease: Bool { - didSet { writeDictation("newline_after_release", newlineAfterRelease) } - } - @Published var muteOutput: Bool { didSet { writeDictation("mute_output", muteOutput) } } - @Published var model: String { didSet { writeDictation("model", model) } } - - @Published var recordingsDir: String { didSet { write("recordings_dir", recordingsDir) } } - @Published var transcriptionEnabled: Bool { - didSet { writeTranscription("enabled", transcriptionEnabled) } - } - @Published var micVoiceProcessing: Bool { - didSet { write("mic_voice_processing", micVoiceProcessing) } - } - /// Debounced, unlike every other control: a keystroke is not a decision, - /// and writing per character would rewrite the file — and wake the - /// watcher — a dozen times while someone types a command. - @Published var onStop: String { didSet { scheduleOnStopWrite() } } - - @Published var meetingDetection: Bool { didSet { write("meeting_detection", meetingDetection) } } - @Published var meetingAutoRecord: Bool { - didSet { write("meeting_auto_record", meetingAutoRecord) } } - @Published private(set) var excludedApps: [ExcludedApp] - - /// Suppresses the write-through while `init` fills the properties in. - private var loading = true - private var onStopWrite: Task? - - init() { - hotkey = HotkeyMonitor.Key(name: Config.hotkey() ?? "")?.rawValue - ?? HotkeyMonitor.Key.fn.rawValue - tapToToggle = Config.tapToToggle() - overlay = Config.overlayEnabled() - newlineAfterRelease = Config.newlineAfterRelease() - muteOutput = Config.muteOutputWhileDictating() - model = Config.dictationModel().flatMap { ModelRegistry.find($0)?.id } - ?? ModelRegistry.recommended()?.id ?? "" - recordingsDir = Self.abbreviated(Config.recordingsDir() ?? Config.defaultRoot) - transcriptionEnabled = Config.transcriptionEnabled() - micVoiceProcessing = Config.micVoiceProcessing() - onStop = Config.onStop() ?? "" - meetingDetection = Config.meetingDetectionEnabled() - meetingAutoRecord = Config.meetingAutoRecord() - excludedApps = Config.meetingExcludedApps().map(Self.resolve) - loading = false - } - - // MARK: actions - func chooseRecordingsDir() { - let panel = NSOpenPanel() - panel.canChooseDirectories = true - panel.canChooseFiles = false - panel.allowsMultipleSelection = false - panel.prompt = "Choose" - panel.directoryURL = Config.recordingsDir() ?? Config.defaultRoot - guard panel.runModal() == .OK, let url = panel.url else { return } - recordingsDir = Self.abbreviated(url) - } - - func addExcludedApp() { - let panel = NSOpenPanel() - panel.canChooseDirectories = false - panel.canChooseFiles = true - panel.allowsMultipleSelection = false - panel.allowedContentTypes = [.applicationBundle] - panel.directoryURL = URL(fileURLWithPath: "/Applications", isDirectory: true) - panel.prompt = "Ignore" - guard panel.runModal() == .OK, let url = panel.url else { return } - // An app with no identifier in its Info.plist has nothing we could - // store, and nothing to match a capture pid against later. - guard let bundleID = Bundle(url: url)?.bundleIdentifier else { return } - guard !excludedApps.contains(where: { $0.id == bundleID }) else { return } - excludedApps.append(Self.resolve(bundleID)) - writeExcludedApps() - } - - func removeExcludedApp(_ bundleID: String) { - excludedApps.removeAll { $0.id == bundleID } - writeExcludedApps() - } - - func openConfigFile() { - Config.ensureFileExists() - // Before it opens, not after: a config written by an earlier yap has - // no line for the settings added since, and this is the moment someone - // is looking for them. - Config.ensureEveryKeyPresent() - NSWorkspace.shared.open(Config.path) + /// One line under the title. Says what the pane is for, so nobody has to + /// infer it from the controls. + var summary: String { + switch self { + case .dictation: return "The key you hold, and what happens when you let go." + case .recording: return "Where sessions land, and what runs after one." + case .meetings: return "Whether yap offers to record when something else takes the mic." + } } - static func label(for key: HotkeyMonitor.Key) -> String { - switch key { - case .fn: return "Fn (Globe)" - case .rightOption: return "Right Option ⌥" - case .rightCommand: return "Right Command ⌘" - case .rightControl: return "Right Control ⌃" - case .rightShift: return "Right Shift ⇧" + var symbol: String { + switch self { + case .dictation: return "waveform" + case .recording: return "recordingtape" + case .meetings: return "person.wave.2.fill" } } - // MARK: writing - - private func write(_ key: String, _ value: Any?) { - guard !loading else { return } - Config.update { config in - if let value { - config[key] = value - } else { - config.removeValue(forKey: key) - } + /// Rail chips only, for scanning. Red goes to Recording deliberately: it + /// is the record light, the same thing it means everywhere else in yap. + var tint: Color { + switch self { + case .dictation: return .accentColor + case .recording: return .red + case .meetings: return .indigo } } +} - private func writeDictation(_ key: String, _ value: Any) { - writeSection("dictation", key, value) - } +// MARK: - View - private func writeTranscription(_ key: String, _ value: Any) { - writeSection("transcription", key, value) - } +private struct SettingsView: View { + @StateObject private var model = SettingsModel() + @State private var pane: SettingsPane = .dictation - /// A nested key, creating the object if the file has never had one. - private func writeSection(_ section: String, _ key: String, _ value: Any) { - guard !loading else { return } - Config.update { config in - var object = config[section] as? [String: Any] ?? [:] - object[key] = value - config[section] = object + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 0) { + rail + Divider() + detail + } + Divider() + footer } + .frame(maxWidth: .infinity, maxHeight: .infinity) } - private func writeExcludedApps() { - write("meeting_excluded_apps", excludedApps.map(\.id)) - } - - private func scheduleOnStopWrite() { - guard !loading else { return } - onStopWrite?.cancel() - let command = onStop.trimmingCharacters(in: .whitespacesAndNewlines) - onStopWrite = Task { @MainActor [weak self] in - try? await Task.sleep(for: .milliseconds(600)) - guard !Task.isCancelled else { return } - // An empty hook is no hook: drop the key rather than leave an - // empty string for `Config.onStop()` to filter out forever. - self?.write("on_stop", command.isEmpty ? nil : command) + private var rail: some View { + List(SettingsPane.allCases, selection: $pane) { item in + Label { + Text(item.title) + } icon: { + Image(systemName: item.symbol) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.white) + .frame(width: 20, height: 20) + .background(item.tint, in: RoundedRectangle(cornerRadius: 5)) + } + .tag(item) } - } - - // MARK: lookups - - private static func abbreviated(_ url: URL) -> String { - (url.path as NSString).abbreviatingWithTildeInPath - } + .listStyle(.sidebar) + .scrollContentBackground(.hidden) + .scrollDisabled(true) + .background(SidebarMaterial(material: .sidebar)) + .frame(width: 186) + } + + private var detail: some View { + VStack(alignment: .leading, spacing: 0) { + VStack(alignment: .leading, spacing: 2) { + Text(pane.title) + .font(.system(size: 15, weight: .semibold)) + Text(pane.summary) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 20) + .padding(.top, 18) - /// Bundle id back to something a person recognises. An app that has since - /// been deleted keeps its place in the list under its raw identifier — - /// removing an exclusion the user cannot see is not ours to decide. - private static func resolve(_ bundleID: String) -> ExcludedApp { - guard let url = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleID) else { - return ExcludedApp( - id: bundleID, - name: bundleID, - icon: NSWorkspace.shared.icon(for: .applicationBundle) - ) + switch pane { + case .dictation: DictationPane(model: model) + case .recording: RecordingPane(model: model) + case .meetings: MeetingsPane(model: model) + } } - return ExcludedApp( - id: bundleID, - name: FileManager.default.displayName(atPath: url.path), - icon: NSWorkspace.shared.icon(forFile: url.path) - ) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + /// The file every control above writes to, named rather than hidden — the + /// window is a GUI over it, and saying so is what makes hand-editing and + /// clicking feel like the same act. + private var footer: some View { + HStack(spacing: 10) { + Text((Config.path.path as NSString).abbreviatingWithTildeInPath) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.head) + Spacer(minLength: 8) + Button("Open Config File") { model.openConfigFile() } + .controlSize(.small) + } + .padding(.horizontal, 14) + .padding(.vertical, 9) + .background(.bar) } } +