Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions Cotabby/App/Core/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,26 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
}

/// Launch Services sends a reopen event when the user opens Cotabby while this accessory app is
/// already running. When the status item is hidden, Settings is the app's only visible recovery
/// surface, so reopening must reveal it instead of leaving the user with no apparent response.
func applicationShouldHandleReopen(
_ sender: NSApplication,
hasVisibleWindows flag: Bool
) -> Bool {
guard !suggestionSettings.isMenuBarIconVisible else {
return true
}

guard !flag else {
return false
}

CotabbyLogger.app.info("Opening Settings because Cotabby was reopened with its menu bar icon hidden")
settingsCoordinator.showSettings()
return false
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

/// One-time default: enable Open at Login for every user (new and existing) the first time this
/// build runs. The applied-flag persists the decision so any later opt-out the user makes is
/// respected on subsequent launches and we only ever flip the toggle once per user.
Expand Down
21 changes: 19 additions & 2 deletions Cotabby/App/Core/CotabbyApp.swift
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import SwiftUI

/// File overview:
/// Declares the SwiftUI app entry point and hosts the single menu-bar scene that renders
/// Declares the SwiftUI app entry point and hosts the optional menu-bar scene that renders
/// Cotabby's compact status UI. Shared services are injected through `AppDelegate`.
///
/// `@main` marks the single process entry point for a Swift app.
@main
struct CotabbyApp: App {
/// Bridges old-style AppKit lifecycle callbacks into a SwiftUI app.
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
/// Scene declarations cannot directly observe the delegate's nested settings object. This
/// projection watches the same durable key so SwiftUI re-evaluates status-item insertion as
/// soon as Settings changes it; the settings model remains the app-facing preference API.
@AppStorage(SuggestionSettingsStore.menuBarIconVisibleDefaultsKey)
private var isMenuBarIconVisible = true

/// Defines the menu bar extra that surfaces Cotabby's runtime, focus, and suggestion state.
var body: some Scene {
MenuBarExtra {
MenuBarExtra(isInserted: menuBarIconVisibilityBinding) {
MenuBarView(
permissionManager: appDelegate.permissionManager,
runtimeModel: appDelegate.runtimeModel,
Expand Down Expand Up @@ -44,4 +49,16 @@ struct CotabbyApp: App {
}
.menuBarExtraStyle(.window)
}

/// SwiftUI owns insertion/removal of the status item, while the settings model remains the
/// single durable source of truth. The setter also captures removal initiated by the system.
private var menuBarIconVisibilityBinding: Binding<Bool> {
Binding(
get: { isMenuBarIconVisible },
set: { visible in
isMenuBarIconVisible = visible
appDelegate.suggestionSettings.setMenuBarIconVisible(visible)
}
)
Comment on lines +55 to +62
}
}
3 changes: 3 additions & 0 deletions Cotabby/Models/SuggestionSettingsData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ struct SuggestionSettingsData: Equatable {
/// green preview while typing, disable it, or use both behaviors together.
var automaticallyFixTypos: Bool
var isPerformanceTrackingEnabled: Bool
/// Controls whether SwiftUI inserts Cotabby's `MenuBarExtra` into the system menu bar. The app
/// keeps running when this is false; reopening Cotabby provides the recovery path to Settings.
var isMenuBarIconVisible: Bool
var isMenuBarWordCountVisible: Bool
var mirrorPreference: MirrorPreference
var userName: String
Expand Down
13 changes: 13 additions & 0 deletions Cotabby/Models/SuggestionSettingsModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,9 @@ final class SuggestionSettingsModel: ObservableObject {
/// default user never pays any extra storage or write cost — recording only kicks in once the
/// user opts in from Settings.
@Published private(set) var isPerformanceTrackingEnabled: Bool
/// Whether Cotabby's status item is inserted into the menu bar. The process and suggestion
/// pipeline remain active when hidden; launching the app again opens Settings as the recovery path.
@Published private(set) var isMenuBarIconVisible: Bool
/// Whether the accepted-word counter is drawn next to the menu bar icon. Off hides the badge
/// entirely; the count itself keeps accruing so toggling it back on restores the running total.
@Published private(set) var isMenuBarWordCountVisible: Bool
Expand Down Expand Up @@ -173,6 +176,7 @@ final class SuggestionSettingsModel: ObservableObject {
enabledSpellingDictionaryCodes = data.enabledSpellingDictionaryCodes
automaticallyFixTypos = data.automaticallyFixTypos
isPerformanceTrackingEnabled = data.isPerformanceTrackingEnabled
isMenuBarIconVisible = data.isMenuBarIconVisible
isMenuBarWordCountVisible = data.isMenuBarWordCountVisible
mirrorPreference = data.mirrorPreference
userName = data.userName
Expand Down Expand Up @@ -460,6 +464,15 @@ final class SuggestionSettingsModel: ObservableObject {
store.savePerformanceTrackingEnabled(enabled)
}

func setMenuBarIconVisible(_ visible: Bool) {
guard isMenuBarIconVisible != visible else {
return
}

isMenuBarIconVisible = visible
store.saveMenuBarIconVisible(visible)
}

func setMenuBarWordCountVisible(_ visible: Bool) {
guard isMenuBarWordCountVisible != visible else {
return
Expand Down
13 changes: 13 additions & 0 deletions Cotabby/Support/SuggestionSettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ struct SuggestionSettingsStore {
private static let spellingDictionaryCodesDefaultsKey = "cotabbyEnabledSpellingDictionaryCodes"
private static let automaticallyFixTyposDefaultsKey = "cotabbyAutomaticallyFixTypos"
private static let performanceTrackingEnabledDefaultsKey = "cotabbyPerformanceTrackingEnabled"
/// Shared with `CotabbyApp` because SwiftUI's scene-level `@AppStorage` is what invalidates the
/// `MenuBarExtra` insertion binding when the settings model writes this preference.
static let menuBarIconVisibleDefaultsKey = "cotabbyMenuBarIconVisible"
private static let menuBarWordCountVisibleDefaultsKey = "cotabbyMenuBarWordCountVisible"
private static let mirrorPreferenceDefaultsKey = "cotabbyMirrorPreference"
private static let userNameDefaultsKey = "cotabbyUserName"
Expand Down Expand Up @@ -215,6 +218,10 @@ struct SuggestionSettingsStore {
// in from the Performance pane.
let resolvedPerformanceTrackingEnabled =
userDefaults.object(forKey: Self.performanceTrackingEnabledDefaultsKey) as? Bool ?? false
// Existing installs keep the status item unless the user explicitly hides it. Hiding the
// item must never terminate the accessory app because autocomplete continues in the background.
let resolvedMenuBarIconVisible =
userDefaults.object(forKey: Self.menuBarIconVisibleDefaultsKey) as? Bool ?? true
// Default to visible so existing installs keep the running-word-count badge they're used
// to seeing. The toggle lets users who find the badge noisy hide it from the menu bar.
let resolvedMenuBarWordCountVisible =
Expand Down Expand Up @@ -369,6 +376,7 @@ struct SuggestionSettingsStore {
enabledSpellingDictionaryCodes: resolvedEnabledSpellingDictionaryCodes,
automaticallyFixTypos: resolvedAutomaticallyFixTypos,
isPerformanceTrackingEnabled: resolvedPerformanceTrackingEnabled,
isMenuBarIconVisible: resolvedMenuBarIconVisible,
isMenuBarWordCountVisible: resolvedMenuBarWordCountVisible,
mirrorPreference: resolvedMirrorPreference,
userName: resolvedUserName,
Expand Down Expand Up @@ -424,6 +432,7 @@ struct SuggestionSettingsStore {
saveEnabledSpellingDictionaryCodes(data.enabledSpellingDictionaryCodes)
saveAutomaticallyFixTypos(data.automaticallyFixTypos)
savePerformanceTrackingEnabled(data.isPerformanceTrackingEnabled)
saveMenuBarIconVisible(data.isMenuBarIconVisible)
saveMenuBarWordCountVisible(data.isMenuBarWordCountVisible)
saveMirrorPreference(data.mirrorPreference)
saveUserName(data.userName)
Expand Down Expand Up @@ -589,6 +598,10 @@ struct SuggestionSettingsStore {
userDefaults.set(enabled, forKey: Self.performanceTrackingEnabledDefaultsKey)
}

func saveMenuBarIconVisible(_ visible: Bool) {
userDefaults.set(visible, forKey: Self.menuBarIconVisibleDefaultsKey)
}

func saveMenuBarWordCountVisible(_ visible: Bool) {
userDefaults.set(visible, forKey: Self.menuBarWordCountVisibleDefaultsKey)
}
Expand Down
16 changes: 16 additions & 0 deletions Cotabby/UI/Settings/Panes/AppearancePaneView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ struct AppearancePaneView: View {
}
.settingsItem(.showWordCount)

Toggle(isOn: menuBarIconVisibleBinding) {
SettingsRowLabel(
title: "Show Cotabby in Menu Bar",
description: "Keep Cotabby's icon in the menu bar. If hidden, open Cotabby again to show Settings.",
systemImage: "menubar.rectangle"
)
}
.settingsItem(.showMenuBarIcon)

Toggle(isOn: showAcceptanceHintBinding) {
HStack(alignment: .firstTextBaseline, spacing: 10) {
Image(systemName: "keyboard")
Expand Down Expand Up @@ -197,6 +206,13 @@ struct AppearancePaneView: View {
)
}

private var menuBarIconVisibleBinding: Binding<Bool> {
Binding(
get: { suggestionSettings.isMenuBarIconVisible },
set: { suggestionSettings.setMenuBarIconVisible($0) }
)
}

private var ghostTextOpacityBinding: Binding<Double> {
Binding(
get: { suggestionSettings.ghostTextOpacity },
Expand Down
10 changes: 9 additions & 1 deletion Cotabby/UI/Settings/SettingsIndex.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ enum SettingsItem: String, CaseIterable, Identifiable {
case streamWhileGenerating
case showFieldIndicator
case showWordCount
case showMenuBarIcon
case showKeyHint
case ghostTextColor
case ghostTextOpacity
Expand Down Expand Up @@ -104,6 +105,7 @@ enum SettingsItem: String, CaseIterable, Identifiable {
case .streamWhileGenerating: return "Stream Suggestions While Generating"
case .showFieldIndicator: return "Show Field Indicator"
case .showWordCount: return "Show Word Count in Menu Bar"
case .showMenuBarIcon: return "Show Cotabby in Menu Bar"
case .showKeyHint: return "Show Accept-Key Hint"
case .ghostTextColor: return "Ghost Text Color"
case .ghostTextOpacity: return "Ghost Text Opacity"
Expand Down Expand Up @@ -171,6 +173,7 @@ enum SettingsItem: String, CaseIterable, Identifiable {
case .streamWhileGenerating: return "text.append"
case .showFieldIndicator: return "dot.viewfinder"
case .showWordCount: return "number"
case .showMenuBarIcon: return "menubar.rectangle"
case .showKeyHint: return "keyboard"
case .ghostTextColor: return "paintpalette"
case .ghostTextOpacity: return "circle.lefthalf.filled"
Expand Down Expand Up @@ -227,7 +230,8 @@ enum SettingsItem: String, CaseIterable, Identifiable {
case .enableGlobally, .fastMode, .openAtLogin, .includeClipboardContext, .includeAppContext,
.allowMultiLine, .inlineMacros, .onboarding:
return .general
case .suggestionDisplay, .streamWhileGenerating, .showFieldIndicator, .showWordCount, .showKeyHint,
case .suggestionDisplay, .streamWhileGenerating, .showFieldIndicator, .showWordCount,
.showMenuBarIcon, .showKeyHint,
.ghostTextColor, .ghostTextOpacity, .ghostTextSize:
return .appearance
case .emojiPicker, .emojiSkinTone, .emojiPeopleStyle, .emojiHistory:
Expand Down Expand Up @@ -273,6 +277,7 @@ enum SettingsItem: String, CaseIterable, Identifiable {
case .streamWhileGenerating: return "Reveal ghost text token by token as the model writes."
case .showFieldIndicator: return "Show a small icon when a field is ready for suggestions."
case .showWordCount: return "Count accepted words next to the menu bar icon."
case .showMenuBarIcon: return "Hide menu bar clutter while Cotabby keeps running."
case .showKeyHint: return "Show the accept-key badge beside the ghost text."
case .ghostTextColor: return "Pick the color of the inline suggestion."
case .ghostTextOpacity: return "How faint the suggestion looks before you accept it."
Expand Down Expand Up @@ -371,6 +376,9 @@ enum SettingsItem: String, CaseIterable, Identifiable {
case .showWordCount:
return ["word count", "words", "menu bar", "menubar", "stats", "counter",
"statistics", "show count"]
case .showMenuBarIcon:
return ["menu bar", "menubar", "status item", "icon", "hide", "hidden",
"clutter", "launch", "reopen", "settings"]
case .showKeyHint:
return ["hint", "badge", "keycap", "accept key", "tip", "label", "key hint",
"show key"]
Expand Down
2 changes: 2 additions & 0 deletions CotabbyTests/SuggestionSettingsModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ final class SuggestionSettingsModelTests: XCTestCase {
model.setOfferTypoCorrections(true)
model.setAutomaticallyFixTypos(true)
model.setPerformanceTrackingEnabled(true)
model.setMenuBarIconVisible(false)
model.setMenuBarWordCountVisible(false)
model.setMirrorPreference(.alwaysMirror)
model.setMultiLineEnabled(true)
Expand Down Expand Up @@ -88,6 +89,7 @@ final class SuggestionSettingsModelTests: XCTestCase {
XCTAssertTrue(reloaded.offerTypoCorrections)
XCTAssertTrue(reloaded.automaticallyFixTypos)
XCTAssertTrue(reloaded.isPerformanceTrackingEnabled)
XCTAssertFalse(reloaded.isMenuBarIconVisible)
XCTAssertFalse(reloaded.isMenuBarWordCountVisible)
XCTAssertEqual(reloaded.mirrorPreference, .alwaysMirror)
XCTAssertTrue(reloaded.isMultiLineEnabled)
Expand Down
10 changes: 10 additions & 0 deletions CotabbyTests/SuggestionSettingsStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,14 @@ final class SuggestionSettingsStoreTests: XCTestCase {
XCTAssertFalse(data.automaticallyFixTypos)
}

func test_load_menuBarIconDefaultsVisible() async {
let defaults = makeIsolatedDefaults()

let data = SuggestionSettingsStore(userDefaults: defaults).load(configuration: .standard)

XCTAssertTrue(data.isMenuBarIconVisible)
}

func test_saveThenLoad_roundTripsScalarFields() async {
let defaults = makeIsolatedDefaults()
let store = SuggestionSettingsStore(userDefaults: defaults)
Expand All @@ -158,6 +166,7 @@ final class SuggestionSettingsStoreTests: XCTestCase {
store.saveGhostTextSizeMultiplier(0.8)
store.saveFastModeEnabled(true)
store.saveAutomaticallyFixTypos(true)
store.saveMenuBarIconVisible(false)
store.saveMenuBarWordCountVisible(false)

let data = store.load(configuration: .standard)
Expand All @@ -168,6 +177,7 @@ final class SuggestionSettingsStoreTests: XCTestCase {
XCTAssertEqual(data.ghostTextSizeMultiplier, 0.8, accuracy: 0.0001)
XCTAssertTrue(data.isFastModeEnabled)
XCTAssertTrue(data.automaticallyFixTypos)
XCTAssertFalse(data.isMenuBarIconVisible)
XCTAssertFalse(data.isMenuBarWordCountVisible)
}

Expand Down
Loading