-
-
Notifications
You must be signed in to change notification settings - Fork 19
Add Writing / Shortcuts / Apps panes to redesigned Settings #366
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| import AppKit | ||
| import SwiftUI | ||
| import UniformTypeIdentifiers | ||
|
|
||
| /// File overview: | ||
| /// "Apps" detail pane of the redesigned Settings window. Lists every app where Cotabby is | ||
| /// disabled, lets the user remove individual rules, and offers a file-picker entry point for apps | ||
| /// that can't be reached from the menu-bar toggle (launchers like Raycast or Spotlight that | ||
| /// dismiss themselves the moment the menu bar is clicked). Lifted from the legacy | ||
| /// `SettingsView.appsSection` so behavior is preserved. | ||
| struct AppsPaneView: View { | ||
| @ObservedObject var suggestionSettings: SuggestionSettingsModel | ||
|
|
||
| var body: some View { | ||
| SettingsPaneScaffold { | ||
| Section("Apps") { | ||
| Text("Cotabby won't autocomplete in these apps. Add an app you can't disable from the " | ||
| + "menu bar, like a launcher that closes the moment it loses focus.") | ||
| .font(.caption) | ||
| .foregroundStyle(.secondary) | ||
|
|
||
| if suggestionSettings.disabledAppRules.isEmpty { | ||
| Text("No apps are disabled. Cotabby is active in every supported field.") | ||
| .font(.callout) | ||
| .foregroundStyle(.secondary) | ||
| } else { | ||
| ForEach(suggestionSettings.disabledAppRules) { rule in | ||
| disabledAppRuleRow(rule) | ||
| } | ||
| } | ||
|
|
||
| Button("Add App…") { | ||
| presentDisabledAppPicker() | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| @ViewBuilder | ||
| private func disabledAppRuleRow(_ rule: DisabledApplicationRule) -> some View { | ||
| HStack(spacing: 12) { | ||
| Image(nsImage: icon(for: rule)) | ||
| .resizable() | ||
| .frame(width: 28, height: 28) | ||
| .accessibilityHidden(true) | ||
|
|
||
| VStack(alignment: .leading, spacing: 2) { | ||
| Text(rule.displayName) | ||
|
|
||
| Text(rule.bundleIdentifier) | ||
| .font(.caption) | ||
| .foregroundStyle(.secondary) | ||
| .textSelection(.enabled) | ||
| } | ||
|
|
||
| Spacer(minLength: 0) | ||
|
|
||
| Button { | ||
| suggestionSettings.removeDisabledApplication( | ||
| bundleIdentifier: rule.bundleIdentifier | ||
| ) | ||
| } label: { | ||
| Image(systemName: "xmark.circle.fill") | ||
| .foregroundStyle(.secondary) | ||
| } | ||
| .buttonStyle(.borderless) | ||
| } | ||
| } | ||
|
|
||
| /// Bundle IDs are durable; app paths are not. Resolve the current app URL at render time so | ||
| /// Settings naturally picks up app updates, moves, or reinstalls without persisting UI cache. | ||
| private func icon(for rule: DisabledApplicationRule) -> NSImage { | ||
| guard let appURL = NSWorkspace.shared.urlForApplication( | ||
| withBundleIdentifier: rule.bundleIdentifier | ||
| ) else { | ||
| return NSWorkspace.shared.icon(for: .applicationBundle) | ||
| } | ||
| return NSWorkspace.shared.icon(forFile: appURL.path) | ||
| } | ||
|
|
||
| /// Lets the user disable Cotabby in an app they can't reach from the menu bar. The menu-bar | ||
| /// "Enable in <app>" switch only targets the frontmost app, so a launcher like Raycast or | ||
| /// Spotlight (which dismisses itself the instant the menu bar is clicked) can never be turned | ||
| /// off that way. An open panel names any installed app whether or not it is running. | ||
| private func presentDisabledAppPicker() { | ||
| let panel = NSOpenPanel() | ||
| panel.allowedContentTypes = [.application] | ||
| panel.allowsMultipleSelection = true | ||
| panel.canChooseDirectories = false | ||
| panel.canChooseFiles = true | ||
| panel.directoryURL = URL(fileURLWithPath: "/Applications", isDirectory: true) | ||
| panel.prompt = "Disable" | ||
| panel.message = "Choose apps where Cotabby should not autocomplete." | ||
|
|
||
| guard panel.runModal() == .OK else { | ||
| return | ||
| } | ||
|
|
||
| for url in panel.urls { | ||
| guard let metadata = ApplicationBundleMetadata(appURL: url) else { | ||
| continue | ||
| } | ||
| suggestionSettings.disableApplication( | ||
| bundleIdentifier: metadata.bundleIdentifier, | ||
| displayName: metadata.displayName | ||
| ) | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,126 @@ | ||
| import SwiftUI | ||
|
|
||
| /// File overview: | ||
| /// "Shortcuts" detail pane of the redesigned Settings window. Surfaces the two keybindings that | ||
| /// drive suggestion acceptance: word-by-word and full-suggestion. Lifted from the legacy | ||
| /// `SettingsView.shortcutsSection` so binding capture, conflict resolution, and reset / clear | ||
| /// semantics are preserved exactly. | ||
| struct ShortcutsPaneView: View { | ||
| @ObservedObject var suggestionSettings: SuggestionSettingsModel | ||
|
|
||
| @State private var isRecordingKeybind = false | ||
| @State private var isRecordingFullAcceptKeybind = false | ||
|
|
||
| var body: some View { | ||
| SettingsPaneScaffold { | ||
| Section("Shortcuts") { | ||
| LabeledContent("Accept Word") { | ||
| KeybindRow( | ||
| label: suggestionSettings.acceptanceKeyLabel, | ||
| keyCode: suggestionSettings.acceptanceKeyCode, | ||
| modifiers: suggestionSettings.acceptanceKeyModifiers, | ||
| defaultKeyCode: SuggestionSettingsModel.defaultAcceptanceKeyCode, | ||
| isRecording: $isRecordingKeybind, | ||
| onRecord: { keyCode, modifiers, label in | ||
| suggestionSettings.setAcceptanceKey( | ||
| keyCode: keyCode, | ||
| modifiers: modifiers, | ||
| label: label | ||
| ) | ||
| }, | ||
| onReset: { | ||
| suggestionSettings.setAcceptanceKey( | ||
| keyCode: SuggestionSettingsModel.defaultAcceptanceKeyCode, | ||
| modifiers: [], | ||
| label: SuggestionSettingsModel.defaultAcceptanceKeyLabel | ||
| ) | ||
| }, | ||
| onClear: { suggestionSettings.clearAcceptanceKey() }, | ||
| clearHelp: "Unbind this shortcut. No key will accept word-by-word." | ||
| ) | ||
| } | ||
|
|
||
| LabeledContent("Accept Entire Suggestion") { | ||
| KeybindRow( | ||
| label: suggestionSettings.fullAcceptanceKeyLabel, | ||
| keyCode: suggestionSettings.fullAcceptanceKeyCode, | ||
| modifiers: suggestionSettings.fullAcceptanceKeyModifiers, | ||
| defaultKeyCode: SuggestionSettingsModel.defaultFullAcceptanceKeyCode, | ||
| isRecording: $isRecordingFullAcceptKeybind, | ||
| onRecord: { keyCode, modifiers, label in | ||
| suggestionSettings.setFullAcceptanceKey( | ||
| keyCode: keyCode, | ||
| modifiers: modifiers, | ||
| label: label | ||
| ) | ||
| }, | ||
| onReset: { | ||
| suggestionSettings.setFullAcceptanceKey( | ||
| keyCode: SuggestionSettingsModel.defaultFullAcceptanceKeyCode, | ||
| modifiers: [], | ||
| label: SuggestionSettingsModel.defaultFullAcceptanceKeyLabel | ||
| ) | ||
| }, | ||
| onClear: { suggestionSettings.clearFullAcceptanceKey() }, | ||
| clearHelp: "Unbind this shortcut. No key will accept the whole suggestion at once." | ||
| ) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Shared row chrome for one keybinding. Owns the badge / Change / Reset / Clear layout and the | ||
| /// `KeyRecorderView` recording state hand-off so the surrounding pane stays focused on what each | ||
| /// binding does rather than how it is rendered. | ||
| private struct KeybindRow: View { | ||
| let label: String | ||
| let keyCode: CGKeyCode | ||
| let modifiers: ShortcutModifierMask | ||
| let defaultKeyCode: CGKeyCode | ||
| @Binding var isRecording: Bool | ||
| let onRecord: (CGKeyCode, ShortcutModifierMask, String) -> Void | ||
| let onReset: () -> Void | ||
| let onClear: () -> Void | ||
| let clearHelp: String | ||
|
Comment on lines
+76
to
+85
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time! |
||
|
|
||
| var body: some View { | ||
| HStack(spacing: 8) { | ||
| Text(label) | ||
| .padding(.horizontal, 8) | ||
| .padding(.vertical, 4) | ||
| .background( | ||
| RoundedRectangle(cornerRadius: 6) | ||
| .fill(.quaternary) | ||
| ) | ||
|
|
||
| if isRecording { | ||
| KeyRecorderView( | ||
| onKeyRecorded: { keyCode, modifiers, label in | ||
| onRecord(keyCode, modifiers, label) | ||
| isRecording = false | ||
| }, | ||
| onCancelled: { isRecording = false } | ||
| ) | ||
| } else { | ||
| Button("Change") { | ||
| isRecording = true | ||
| } | ||
| } | ||
|
|
||
| if keyCode != defaultKeyCode || !modifiers.isEmpty { | ||
| Button("Reset") { | ||
| onReset() | ||
| isRecording = false | ||
| } | ||
| } | ||
|
|
||
| if keyCode != SuggestionSettingsModel.disabledKeyCode { | ||
| Button("Clear") { | ||
| onClear() | ||
| isRecording = false | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
isRecordingKeybindandisRecordingFullAcceptKeybindare independent, so both can betruesimultaneously. When that happens, bothKeyRecorderViewinstances callNSEvent.addLocalMonitorForEvents. Local monitors fire in LIFO order, so whichever "Change" button was clicked second will capture the next key — even if the user intended it for the first row. The firstKeyRecorderViewthen stays in "Press a key…" state until a further key is pressed, silently recording a second shortcut the user didn't intend. Setting the other flag tofalse(or using a singleactiveRecorderenum) when one starts recording would prevent this.