diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index 8d1a13a..5bc1a3d 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -13,6 +13,7 @@ 0431AE1DBEE36C90C7F39C19 /* CustomRulesCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = E43E587E421AF544A8300CE4 /* CustomRulesCatalog.swift */; }; 0A2DDD946654076675AC0FC6 /* LanguageCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF4BB93056F291FD24EFAD22 /* LanguageCatalog.swift */; }; 0A3443AEE6540F11E5E6BF8F /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3E8E86A14090BC7BD13BA76 /* AppDelegate.swift */; }; + 0A658BF137DBD0898E40B87F /* AcknowledgementsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B7A28471B8526C2693FFF65 /* AcknowledgementsView.swift */; }; 0AF568AB234033BA2DE4CAA7 /* SuggestionModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 386C98FFCF76EC1C8C7E82BB /* SuggestionModels.swift */; }; 0C06CAD62975E87B2C852191 /* ScreenTextExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59E299BE2E9D42A33D5D2F5D /* ScreenTextExtractor.swift */; }; 0C98ECB5BCEBA72C693AC1C9 /* SuggestionTextNormalizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B55A4362AB7F0528C661C4C /* SuggestionTextNormalizerTests.swift */; }; @@ -49,11 +50,13 @@ 32A2915FAE21CD9CE818A9D9 /* SuggestionSettingsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86460C747AA883FDE756BDBA /* SuggestionSettingsModel.swift */; }; 344B9BF352C97CFA830853D6 /* WelcomePermissionStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D6C2318E405AA717D1C256 /* WelcomePermissionStepView.swift */; }; 36312821AEE03E3E62845958 /* FoundationModelPromptRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE7BF162A12703249726F20A /* FoundationModelPromptRenderer.swift */; }; + 39571AB31481959CD5C223AE /* PermissionsPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7113D3373525113CA69E7597 /* PermissionsPaneView.swift */; }; 3985F0F2B3178DBB945B1064 /* CompletionRenderModePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53CF416511099C6818110F01 /* CompletionRenderModePolicy.swift */; }; 3B3E08D1204E85F3776D8853 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = BAADA69C6172DD7F4A642E93 /* Sparkle */; }; 3C23336EE6F6559857DE92EE /* SuggestionDebugLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 003594B09C83EF2DF35577D5 /* SuggestionDebugLogger.swift */; }; 3CBBC3BFAC0DC8952EE24EF7 /* BundledRuntimeLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA33F5FFAC5B99384E15CE3E /* BundledRuntimeLocator.swift */; }; 3CF1A4E39F24917DF0470A7D /* PromptPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4696A84D17890B154533A08F /* PromptPolicyTests.swift */; }; + 4134ADBE464D00BB748BD9AE /* GeneralPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07480CE96ED0EBD94817C6B1 /* GeneralPaneView.swift */; }; 4190F8A76196B16ED94D0A55 /* VisualContextModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE97A8169438D593C6C23412 /* VisualContextModels.swift */; }; 42D40F37086294D0E58200C5 /* GhostFontSizeStabilizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9458F0820B3161FE9CF1DDAF /* GhostFontSizeStabilizer.swift */; }; 46F341472191BC451B6BF6B5 /* SuggestionRequestFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE858CB1E687E3CEB8FDD5B /* SuggestionRequestFactory.swift */; }; @@ -92,6 +95,7 @@ 6E49ADEB31D04DC77A47DEB0 /* FileLogHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8C2569A8217EE9BD3B197F /* FileLogHandler.swift */; }; 744B06C2488156B178675615 /* PermissionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85BF316556FDA64CB8AD07B6 /* PermissionManager.swift */; }; 76FD91607794883F8E121450 /* CaretGeometrySelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3C84377F352140759B448C9 /* CaretGeometrySelector.swift */; }; + 78FAE5DB691A1B71042B9D20 /* AboutPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3FA53BBC3D81503C1D17477 /* AboutPaneView.swift */; }; 7B6A63F5DCC2C163CDFD2A5C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = BC4F887528AE74AC0DD30314 /* Assets.xcassets */; }; 7C94725B4837DEC9ECF1BC54 /* CompletionRenderMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A03E565A11581FD2150B142 /* CompletionRenderMode.swift */; }; 7D6BB9AF72F7076A4E5EE96F /* DownloadableModelCatalogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB5C2AE9A7E55495D26AD074 /* DownloadableModelCatalogView.swift */; }; @@ -187,6 +191,7 @@ 043E8AA850F930222DD112C0 /* GhostSuggestionLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhostSuggestionLayout.swift; sourceTree = ""; }; 04E25414C307A20B6F9F20EC /* FocusSnapshotResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusSnapshotResolver.swift; sourceTree = ""; }; 050D929E13BE52E6282B64D2 /* VisualContextStartCoalescerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualContextStartCoalescerTests.swift; sourceTree = ""; }; + 07480CE96ED0EBD94817C6B1 /* GeneralPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralPaneView.swift; sourceTree = ""; }; 09FADF683BE7B3558377FA76 /* FocusPollBackoff.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusPollBackoff.swift; sourceTree = ""; }; 0A3D1125B962CBE0269EEDDB /* SuggestionInserter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionInserter.swift; sourceTree = ""; }; 0C383AE85B971A9605787358 /* FocusModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusModels.swift; sourceTree = ""; }; @@ -208,6 +213,7 @@ 262BE2F1E97389FE8D7A5FB9 /* Cotabby.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Cotabby.app; sourceTree = BUILT_PRODUCTS_DIR; }; 264CA64B2AB1611F82E5B760 /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; 273B4DC844F79B4BE2C8910F /* FocusPollBackoffTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusPollBackoffTests.swift; sourceTree = ""; }; + 2B7A28471B8526C2693FFF65 /* AcknowledgementsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcknowledgementsView.swift; sourceTree = ""; }; 2D1F9CEBAB0F330F8E7B61D8 /* InputSuppressionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputSuppressionController.swift; sourceTree = ""; }; 2F01FAC4F57EB08471521196 /* VisualContextStartCoalescer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualContextStartCoalescer.swift; sourceTree = ""; }; 3009812A35A1CDEF16295AB7 /* LlamaPromptRendererTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LlamaPromptRendererTests.swift; sourceTree = ""; }; @@ -246,6 +252,7 @@ 6C4565D64DF64A2AA0DB1532 /* WelcomeProfileStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeProfileStepView.swift; sourceTree = ""; }; 70367FCC1E0F08EE3B8EB26F /* FocusCapabilityResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusCapabilityResolver.swift; sourceTree = ""; }; 711293EA57808B9428C7B908 /* CotabbyAppEnvironment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CotabbyAppEnvironment.swift; sourceTree = ""; }; + 7113D3373525113CA69E7597 /* PermissionsPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionsPaneView.swift; sourceTree = ""; }; 72B13136DF7318F3E96DF0D3 /* SuggestionCoordinator+Acceptance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionCoordinator+Acceptance.swift"; sourceTree = ""; }; 74BD1D4DB27D5D96D1E06096 /* DisplayCoordinateConverter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayCoordinateConverter.swift; sourceTree = ""; }; 77B0121E7BB173F8A2B0B108 /* WindowScreenshotService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WindowScreenshotService.swift; sourceTree = ""; }; @@ -277,6 +284,7 @@ 9D82FFC568527700EC17C07D /* PermissionModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionModels.swift; sourceTree = ""; }; A168A7B6A7AD11559B60C56B /* ApplicationBundleMetadataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationBundleMetadataTests.swift; sourceTree = ""; }; A3E8E86A14090BC7BD13BA76 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + A3FA53BBC3D81503C1D17477 /* AboutPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutPaneView.swift; sourceTree = ""; }; A520809E71697E3BB9A8139C /* HuggingFaceModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HuggingFaceModels.swift; sourceTree = ""; }; A52D0B550E00EF173A5D157E /* LlamaRuntimeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LlamaRuntimeManager.swift; sourceTree = ""; }; A804F4DB6FD9BC8C27B2B65F /* LlamaRuntimeModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LlamaRuntimeModels.swift; sourceTree = ""; }; @@ -416,6 +424,10 @@ 2EE526D7C9284AF6687A556B /* Panes */ = { isa = PBXGroup; children = ( + A3FA53BBC3D81503C1D17477 /* AboutPaneView.swift */, + 2B7A28471B8526C2693FFF65 /* AcknowledgementsView.swift */, + 07480CE96ED0EBD94817C6B1 /* GeneralPaneView.swift */, + 7113D3373525113CA69E7597 /* PermissionsPaneView.swift */, 93028F328388432E72C58D09 /* PlaceholderPaneView.swift */, ); path = Panes; @@ -796,6 +808,8 @@ files = ( 30F3F2B6D13CD583136CD787 /* AXHelper.swift in Sources */, D9C51DEDF01033E276A479CE /* AXTextGeometryResolver.swift in Sources */, + 78FAE5DB691A1B71042B9D20 /* AboutPaneView.swift in Sources */, + 0A658BF137DBD0898E40B87F /* AcknowledgementsView.swift in Sources */, 26E0331E9E2F92FAE531BDEE /* ActivationIndicatorController.swift in Sources */, 0A3443AEE6540F11E5E6BF8F /* AppDelegate.swift in Sources */, C4C6734678797669055988E0 /* AppUpdateManager.swift in Sources */, @@ -829,6 +843,7 @@ D0D4C0E28F5CD99669A49414 /* FoundationModelAvailabilityService.swift in Sources */, 36312821AEE03E3E62845958 /* FoundationModelPromptRenderer.swift in Sources */, 6DD1E22151571E1A22FF22F4 /* FoundationModelSuggestionEngine.swift in Sources */, + 4134ADBE464D00BB748BD9AE /* GeneralPaneView.swift in Sources */, 42D40F37086294D0E58200C5 /* GhostFontSizeStabilizer.swift in Sources */, ED9C51B0D7056F0753AADF2D /* GhostSuggestionLayout.swift in Sources */, F4EEE6291095B0BF2D3FBA21 /* GhostTextColorPreset.swift in Sources */, @@ -866,6 +881,7 @@ 6106B16C0DBA94EBF838D93E /* PermissionOverlayTracker.swift in Sources */, 61EC9D635D416115E7C96E0F /* PermissionOverlayWindowController.swift in Sources */, 90DC9508F27F712EB61EEB06 /* PermissionReminderView.swift in Sources */, + 39571AB31481959CD5C223AE /* PermissionsPaneView.swift in Sources */, BBE74B21ED1543DEACF18A1A /* PlaceholderPaneView.swift in Sources */, 98E2E14A069384C1088CDB44 /* PromptContextSanitizer.swift in Sources */, 82D4ADEAF05337ABDE4C586C /* RuntimeBootstrapModel.swift in Sources */, diff --git a/Cotabby/UI/Settings/Panes/AboutPaneView.swift b/Cotabby/UI/Settings/Panes/AboutPaneView.swift new file mode 100644 index 0000000..9f4d580 --- /dev/null +++ b/Cotabby/UI/Settings/Panes/AboutPaneView.swift @@ -0,0 +1,126 @@ +import AppKit +import SwiftUI + +/// File overview: +/// "About" detail pane of the redesigned Settings window. Consolidates what used to live across +/// three legacy sections (header, support CTA, uninstall) plus a new Acknowledgements modal that +/// lists the third-party packages Cotabby ships with. +struct AboutPaneView: View { + let appUpdateManager: AppUpdateManager + + @State private var isShowingAcknowledgements = false + + var body: some View { + SettingsPaneScaffold { + Section { aboutHeader } + Section("Support") { supportRow } + Section("Links") { linksRow } + Section("Uninstall") { uninstallText } + } + .sheet(isPresented: $isShowingAcknowledgements) { + AcknowledgementsView { isShowingAcknowledgements = false } + } + } + + @ViewBuilder + private var aboutHeader: some View { + HStack(spacing: 12) { + Image("CotabbyLogo") + .resizable() + .scaledToFit() + .frame(width: 40, height: 40) + .clipShape(RoundedRectangle(cornerRadius: 9, style: .continuous)) + + VStack(alignment: .leading, spacing: 2) { + Text("Cotabby") + .font(.system(size: 16, weight: .semibold, design: .rounded)) + + Text("Local macOS AI Autocomplete") + .font(.system(size: 12, design: .rounded)) + .foregroundStyle(.secondary) + + Text(appVersionText) + .font(.system(size: 11, design: .rounded)) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 12) + + Button("Check for Updates") { + appUpdateManager.checkForUpdates() + } + } + .padding(.vertical, 4) + } + + @ViewBuilder + private var supportRow: some View { + LabeledContent { + if let supportURL = URL(string: "https://ko-fi.com/cotabby") { + Link(destination: supportURL) { + Label("Support", systemImage: "heart.fill") + } + .buttonStyle(.borderedProminent) + .tint(.blue) + } + } label: { + Text( + "Cotabby is free and open source, maintained by two university students in our free time. " + + "If it's useful to you, please consider supporting development." + ) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + @ViewBuilder + private var linksRow: some View { + VStack(alignment: .leading, spacing: 8) { + if let repoURL = URL(string: "https://github.com/FuJacob/Cotabby") { + Link(destination: repoURL) { + Label("GitHub Repository", systemImage: "chevron.left.forwardslash.chevron.right") + } + } + if let wikiURL = URL(string: "https://github.com/FuJacob/Cotabby/wiki") { + Link(destination: wikiURL) { + Label("Wiki & Contributor Guide", systemImage: "book") + } + } + Button { + isShowingAcknowledgements = true + } label: { + Label("Acknowledgements", systemImage: "doc.text") + } + .buttonStyle(.link) + } + } + + @ViewBuilder + private var uninstallText: some View { + Text( + "Drag Cotabby.app from Applications to the Trash. " + + "To remove leftover data, also delete ~/Library/Application Support/Cotabby. " + + "Privacy permissions can only be revoked in System Settings → Privacy & Security." + ) + .font(.caption) + .foregroundStyle(.secondary) + } + + /// The app bundle is the canonical source for human-facing version text. + private var appVersionText: String { + let shortVersion = + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String + let buildNumber = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String + + switch (shortVersion, buildNumber) { + case (let shortVersion?, let buildNumber?) where shortVersion != buildNumber: + return "Version \(shortVersion) (\(buildNumber))" + case (let shortVersion?, _): + return "Version \(shortVersion)" + case (_, let buildNumber?): + return "Build \(buildNumber)" + default: + return "Unknown version" + } + } +} diff --git a/Cotabby/UI/Settings/Panes/AcknowledgementsView.swift b/Cotabby/UI/Settings/Panes/AcknowledgementsView.swift new file mode 100644 index 0000000..92f4148 --- /dev/null +++ b/Cotabby/UI/Settings/Panes/AcknowledgementsView.swift @@ -0,0 +1,100 @@ +import SwiftUI + +/// File overview: +/// Modal sheet listing the third-party packages Cotabby ships with. Lightweight by design: each +/// row names the project, summarizes what it does for Cotabby, and links to its repo. The intent +/// is attribution, not a full license dump; the GitHub repo carries the verbatim license texts. +struct AcknowledgementsView: View { + let onClose: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider() + ScrollView { + VStack(alignment: .leading, spacing: 14) { + ForEach(Self.entries) { entry in + AcknowledgementRow(entry: entry) + } + Text( + "Each project ships under its own license; see the linked repository for the " + + "verbatim text." + ) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(20) + } + } + .frame(width: 520, height: 460) + } + + @ViewBuilder + private var header: some View { + HStack { + Text("Acknowledgements") + .font(.system(size: 15, weight: .semibold)) + Spacer(minLength: 0) + Button("Done", action: onClose) + .keyboardShortcut(.defaultAction) + } + .padding(.horizontal, 20) + .padding(.vertical, 12) + } + + private static let entries: [AcknowledgementEntry] = [ + AcknowledgementEntry( + name: "llama.cpp", + summary: "On-device inference engine for GGUF models on the Open Source path.", + url: "https://github.com/ggml-org/llama.cpp" + ), + AcknowledgementEntry( + name: "Sparkle", + summary: "Update framework used by the Check for Updates button.", + url: "https://github.com/sparkle-project/Sparkle" + ), + AcknowledgementEntry( + name: "swift-log", + summary: "Logging façade Cotabby uses across runtime, focus, and suggestion subsystems.", + url: "https://github.com/apple/swift-log" + ), + AcknowledgementEntry( + name: "CotabbyInference", + summary: "Swift wrapper around llama.cpp that exposes the inference API Cotabby links against.", + url: "https://github.com/FuJacob/cotabbyinference" + ) + ] +} + +private struct AcknowledgementEntry: Identifiable { + let name: String + let summary: String + let url: String + + var id: String { name } +} + +private struct AcknowledgementRow: View { + let entry: AcknowledgementEntry + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Text(entry.name) + .font(.system(size: 13, weight: .semibold)) + if let url = URL(string: entry.url) { + Link(destination: url) { + Image(systemName: "arrow.up.right.square") + } + .buttonStyle(.plain) + .foregroundStyle(.secondary) + } + } + Text(entry.summary) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/Cotabby/UI/Settings/Panes/GeneralPaneView.swift b/Cotabby/UI/Settings/Panes/GeneralPaneView.swift new file mode 100644 index 0000000..d18c77f --- /dev/null +++ b/Cotabby/UI/Settings/Panes/GeneralPaneView.swift @@ -0,0 +1,202 @@ +import SwiftUI + +/// File overview: +/// "General" detail pane of the redesigned Settings window. Owns the everyday on/off toggles, the +/// ghost-text appearance controls, and the onboarding re-entry. Lifted intact from the legacy +/// `SettingsView.generalSection` so behavior, bindings, and tooltip copy stay identical; only the +/// scaffolding around the form is new. +struct GeneralPaneView: View { + @ObservedObject var suggestionSettings: SuggestionSettingsModel + let onShowWelcome: () -> Void + + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + SettingsPaneScaffold { + Section("General") { + Toggle("Enable Globally", isOn: globallyEnabledBinding) + + Toggle("Show Indicator", isOn: showIndicatorBinding) + + Toggle(isOn: showAcceptanceHintBinding) { + HStack(spacing: 4) { + Text("Show") + Text(suggestionSettings.acceptanceKeyLabel) + .font(.system(.body, design: .rounded).weight(.semibold)) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(.quaternary) + ) + Text("Key Hint") + } + } + + Picker("Suggestion Display", selection: mirrorPreferenceBinding) { + ForEach(MirrorPreference.allCases) { preference in + Text(preference.displayLabel).tag(preference) + } + } + .pickerStyle(.menu) + .help( + "Auto uses inline ghost text when the focused field exposes a reliable cursor " + + "position, and switches to a popup card when it doesn't (some Electron and web " + + "editors). Choose Inline or Popup to pin one style for every app." + ) + + Toggle("Allow Multi-line Suggestions", isOn: multiLineEnabledBinding) + + Toggle("Accept Punctuation With Word", isOn: autoAcceptTrailingPunctuationBinding) + + Toggle("Include Clipboard Context", isOn: clipboardContextEnabledBinding) + + Toggle("Fast Mode", isOn: fastModeEnabledBinding) + + LabeledContent("Ghost Text Color") { + HStack(spacing: 8) { + ForEach(GhostTextColorPreset.all) { preset in + ghostColorSwatch(for: preset) + } + } + } + + LabeledContent("Ghost Text Opacity") { + HStack(spacing: 10) { + TickMarkSlider( + value: ghostTextOpacityBinding, + range: SuggestionSettingsModel.minimumGhostTextOpacity + ... SuggestionSettingsModel.maximumGhostTextOpacity, + step: SuggestionSettingsModel.ghostTextOpacityStep + ) + .frame(width: 180) + + Text(ghostTextOpacityLabel) + .font(.callout) + .monospacedDigit() + .foregroundStyle(.secondary) + .frame(width: 42, alignment: .trailing) + } + } + + LabeledContent("Onboarding") { + Button("Open Welcome Guide") { + onShowWelcome() + } + } + } + } + } + + // MARK: - Bindings + + private var globallyEnabledBinding: Binding { + Binding( + get: { suggestionSettings.isGloballyEnabled }, + set: { suggestionSettings.setGloballyEnabled($0) } + ) + } + + private var showIndicatorBinding: Binding { + Binding( + get: { suggestionSettings.showIndicator }, + set: { suggestionSettings.setShowIndicator($0) } + ) + } + + private var showAcceptanceHintBinding: Binding { + Binding( + get: { suggestionSettings.showAcceptanceHint }, + set: { suggestionSettings.setShowAcceptanceHint($0) } + ) + } + + private var mirrorPreferenceBinding: Binding { + Binding( + get: { suggestionSettings.mirrorPreference }, + set: { suggestionSettings.setMirrorPreference($0) } + ) + } + + private var multiLineEnabledBinding: Binding { + Binding( + get: { suggestionSettings.isMultiLineEnabled }, + set: { suggestionSettings.setMultiLineEnabled($0) } + ) + } + + private var autoAcceptTrailingPunctuationBinding: Binding { + Binding( + get: { suggestionSettings.autoAcceptTrailingPunctuation }, + set: { suggestionSettings.setAutoAcceptTrailingPunctuation($0) } + ) + } + + private var clipboardContextEnabledBinding: Binding { + Binding( + get: { suggestionSettings.isClipboardContextEnabled }, + set: { suggestionSettings.setClipboardContextEnabled($0) } + ) + } + + private var fastModeEnabledBinding: Binding { + Binding( + get: { suggestionSettings.isFastModeEnabled }, + set: { suggestionSettings.setFastModeEnabled($0) } + ) + } + + private var ghostTextOpacityBinding: Binding { + Binding( + get: { suggestionSettings.ghostTextOpacity }, + set: { suggestionSettings.setGhostTextOpacity($0) } + ) + } + + // MARK: - Ghost color swatch helpers + + /// Mirrors the overlay's automatic fallback (`GhostSuggestionView.ghostColor`) so the Automatic + /// swatch previews the same gray the user will actually see. + private var automaticGhostTextColor: Color { + colorScheme == .dark + ? Color(red: 0.65, green: 0.65, blue: 0.65) + : Color(red: 0.45, green: 0.45, blue: 0.45) + } + + private var ghostTextOpacityLabel: String { + "\(Int((suggestionSettings.ghostTextOpacity * 100).rounded()))%" + } + + @ViewBuilder + private func ghostColorSwatch(for preset: GhostTextColorPreset) -> some View { + let isSelected = GhostTextColorPreset.matching( + hex: suggestionSettings.customSuggestionTextColorHex + ) == preset + + Button { + suggestionSettings.setCustomSuggestionTextColorHex(preset.hex) + } label: { + Circle() + .fill(swatchFill(for: preset)) + .frame(width: 18, height: 18) + .overlay( + Circle() + .strokeBorder( + Color.primary.opacity(isSelected ? 0.9 : 0.18), + lineWidth: isSelected ? 2 : 1 + ) + ) + } + .buttonStyle(.plain) + } + + private func swatchFill(for preset: GhostTextColorPreset) -> Color { + guard let hex = preset.hex, + let color = SuggestionTextColorCodec.color(fromHex: hex) + else { + return automaticGhostTextColor + } + + return color + } +} diff --git a/Cotabby/UI/Settings/Panes/PermissionsPaneView.swift b/Cotabby/UI/Settings/Panes/PermissionsPaneView.swift new file mode 100644 index 0000000..7089c0c --- /dev/null +++ b/Cotabby/UI/Settings/Panes/PermissionsPaneView.swift @@ -0,0 +1,81 @@ +import SwiftUI + +/// File overview: +/// "Permissions" detail pane of the redesigned Settings window. Renders status rows for the three +/// permissions Cotabby requires (Accessibility, Input Monitoring, Screen Recording) and offers a +/// shortcut into the relevant System Settings pane when one of them is missing. +struct PermissionsPaneView: View { + @ObservedObject var permissionManager: PermissionManager + @Environment(\.scenePhase) private var scenePhase + + var body: some View { + SettingsPaneScaffold(callout: callout) { + Section("Permissions") { + Text("Cotabby needs Accessibility, Input Monitoring, and Screen Recording for autocomplete.") + .font(.caption) + .foregroundStyle(.secondary) + + permissionRow( + title: "Accessibility", + granted: permissionManager.accessibilityGranted, + action: permissionManager.openAccessibilitySettings + ) + + permissionRow( + title: "Input Monitoring", + granted: permissionManager.inputMonitoringGranted, + action: permissionManager.openInputMonitoringSettings + ) + + permissionRow( + title: "Screen Recording", + granted: permissionManager.screenRecordingGranted, + action: permissionManager.openScreenRecordingSettings + ) + } + } + .onAppear { permissionManager.refresh() } + // Re-check on scene activation: .onAppear does not fire when returning from + // System Settings, so permission status would otherwise stay stale. + .onChange(of: scenePhase) { _, newPhase in + if newPhase == .active { + permissionManager.refresh() + } + } + } + + /// Top-of-pane callout when any required permission is still missing. The full picture stays + /// in the rows below; this just surfaces the broken state without making the user read each row + /// in turn. + private var callout: SettingsPaneCallout? { + guard !permissionManager.requiredPermissionsGranted else { + return nil + } + return SettingsPaneCallout( + tone: .warning, + message: "Cotabby needs more access to run. Grant the permissions below to enable autocomplete." + ) + } + + @ViewBuilder + private func permissionRow( + title: String, + granted: Bool, + action: @escaping () -> Void + ) -> some View { + HStack(spacing: 10) { + Text(title) + Spacer(minLength: 0) + Text(granted ? "Granted" : "Needs Access") + .font(.caption.weight(.medium)) + .foregroundStyle(granted ? .green : .orange) + + if !granted { + Button("Open Settings") { + action() + } + .controlSize(.small) + } + } + } +} diff --git a/Cotabby/UI/Settings/SettingsContainerView.swift b/Cotabby/UI/Settings/SettingsContainerView.swift index d0af073..24f6836 100644 --- a/Cotabby/UI/Settings/SettingsContainerView.swift +++ b/Cotabby/UI/Settings/SettingsContainerView.swift @@ -58,15 +58,21 @@ struct SettingsContainerView: View { @ViewBuilder private var detailPane: some View { switch selection { - case .general, - .engineAndModel, + case .general: + GeneralPaneView( + suggestionSettings: suggestionSettings, + onShowWelcome: onShowWelcome + ) + case .permissions: + PermissionsPaneView(permissionManager: permissionManager) + case .about: + AboutPaneView(appUpdateManager: appUpdateManager) + case .engineAndModel, .appleIntelligence, .openSource, .writing, .shortcuts, - .apps, - .permissions, - .about: + .apps: PlaceholderPaneView(category: selection) } }