From 7e70e0436010a20cff9093a9d346e7592f70cd1d Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Fri, 12 Jun 2026 00:24:56 -0700 Subject: [PATCH 1/2] feat(settings): searchable Home hub, ranked reveal-to-row search, and a tinted sidebar The Settings window gets a designed landing surface and a search that lands on the exact control: - Home is now a composed page: identity hero, a prominent search field over the whole catalog, status cards (global toggle, engine and model, permissions) and tinted quick links, with the feature demos and a one-line footer below. - Search relevance moves to a pure SettingsSearchRanker (tiered prefix/word/substring/subsequence scoring, multi-token queries, reverse prefix for plurals, diacritic folding). Every catalog item gains a searchable one-line summary, and results render as ranked rich rows. - Picking a result reveals the setting: the pane opens, scrolls to the row, and pulses it. Every indexed setting carries a .settingsItem anchor. - Sidebar rows get System Settings-style tinted icon tiles and spacing-only group clusters. Cmd-F focuses the Home search from any pane. - Declutter: the Ko-fi pitch leaves General (it lives on Home and About), permission rows gain real status iconography, the window title follows the pane via navigationTitle instead of racing NSWindow.title writes. - New -cotabby-open-settings launch argument opens the Settings window at startup so UI work on it can be exercised by tooling. --- Cotabby.xcodeproj/project.pbxproj | 34 ++ .../Coordinators/SettingsCoordinator.swift | 11 +- Cotabby/App/Core/AppDelegate.swift | 7 + Cotabby/Support/SettingsSearchRanker.swift | 213 +++++++ .../Components/SettingsIconTile.swift | 39 ++ .../Components/SettingsPaneScaffold.swift | 57 +- .../Components/SettingsQuickLinkCard.swift | 67 +++ .../Components/SettingsSearchResultRow.swift | 65 +++ Cotabby/UI/Settings/Panes/AboutPaneView.swift | 43 +- .../Settings/Panes/AppearancePaneView.swift | 8 + Cotabby/UI/Settings/Panes/AppsPaneView.swift | 2 + .../UI/Settings/Panes/ContextPaneView.swift | 2 + Cotabby/UI/Settings/Panes/EmojiPaneView.swift | 4 + .../Panes/EngineAndModelPaneView.swift | 11 + .../UI/Settings/Panes/GeneralPaneView.swift | 40 +- Cotabby/UI/Settings/Panes/HomePaneView.swift | 521 ++++++++++++++++-- .../Settings/Panes/PerformancePaneView.swift | 7 + .../Settings/Panes/PermissionsPaneView.swift | 13 +- .../UI/Settings/Panes/ShortcutsPaneView.swift | 4 + .../UI/Settings/Panes/WritingPaneView.swift | 10 + Cotabby/UI/Settings/SettingsCategory.swift | 65 ++- .../UI/Settings/SettingsContainerView.swift | 67 ++- Cotabby/UI/Settings/SettingsIndex.swift | 102 +++- .../UI/Settings/SettingsNavigationModel.swift | 118 ++++ Cotabby/UI/Settings/SettingsSidebarView.swift | 95 ++-- CotabbyTests/SettingsIndexTests.swift | 12 +- CotabbyTests/SettingsSearchRankerTests.swift | 88 +++ 27 files changed, 1500 insertions(+), 205 deletions(-) create mode 100644 Cotabby/Support/SettingsSearchRanker.swift create mode 100644 Cotabby/UI/Settings/Components/SettingsIconTile.swift create mode 100644 Cotabby/UI/Settings/Components/SettingsQuickLinkCard.swift create mode 100644 Cotabby/UI/Settings/Components/SettingsSearchResultRow.swift create mode 100644 Cotabby/UI/Settings/SettingsNavigationModel.swift create mode 100644 CotabbyTests/SettingsSearchRankerTests.swift diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index 71efd8f4..1262ea36 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -46,6 +46,7 @@ 0BEBB33EB75B59EE83C6FE44 /* MenuBarPopoverDismisser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44595B534DD7323F0AD60825 /* MenuBarPopoverDismisser.swift */; }; 0C06CAD62975E87B2C852191 /* ScreenTextExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59E299BE2E9D42A33D5D2F5D /* ScreenTextExtractor.swift */; }; 0C98ECB5BCEBA72C693AC1C9 /* SuggestionTextNormalizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B55A4362AB7F0528C661C4C /* SuggestionTextNormalizerTests.swift */; }; + 0CD30EF91DFB6D95D2964C3F /* SettingsIconTile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17AAA3C022A8AE39FACAAD5 /* SettingsIconTile.swift */; }; 0D15CBF45EB1DB725B9F1A6A /* EmojiQueryRunTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75396860978E81EFAA506CD4 /* EmojiQueryRunTests.swift */; }; 0D8241CD31942A25EC4E0EE4 /* CotabbyDebugOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7B2D34A6F3AC9DFD61350F7 /* CotabbyDebugOptions.swift */; }; 0DDC0CFF5558A8F4355836B2 /* OverlayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F308F6E274CC645E27CB651F /* OverlayController.swift */; }; @@ -124,6 +125,7 @@ 28198855DD83CDABC02A780C /* AXTextGeometryResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB58035EFFD65B767949BAE6 /* AXTextGeometryResolver.swift */; }; 286B7022E2A2774275004447 /* WelcomeTemplateStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9199B9CEAB320982CA333B8 /* WelcomeTemplateStepView.swift */; }; 2872D907299F79A9A69BBFCB /* EmojiPickerPanelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 764659D09C3F0E8FBD267102 /* EmojiPickerPanelController.swift */; }; + 287FC5401537014F688D92E2 /* SettingsIconTile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17AAA3C022A8AE39FACAAD5 /* SettingsIconTile.swift */; }; 28D217A96946A2005FCBEBFD /* emoji.json in Resources */ = {isa = PBXBuildFile; fileRef = C379D77029D6E88C8C1B9AF7 /* emoji.json */; }; 29ABB5488251FD8089D74F51 /* MidWordContinuationPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 357C18383B047F24A531BDCD /* MidWordContinuationPolicy.swift */; }; 2A53558D66C96E963B23CA11 /* CompositionInputModeClassifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC4A3C4BC38793EB11F484F1 /* CompositionInputModeClassifierTests.swift */; }; @@ -167,6 +169,7 @@ 3985F0F2B3178DBB945B1064 /* CompletionRenderModePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53CF416511099C6818110F01 /* CompletionRenderModePolicy.swift */; }; 3AB45217DFC86AFC98C374D6 /* WelcomeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21CB3008986BE7FD2A4D9132 /* WelcomeCoordinator.swift */; }; 3AC2F7F221F7C59242B06DFC /* InputSuppressionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D1F9CEBAB0F330F8E7B61D8 /* InputSuppressionController.swift */; }; + 3AFDA4A5BE9486A1B367815B /* SettingsNavigationModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D1AC5EC06664F49A6AE2B17 /* SettingsNavigationModel.swift */; }; 3B3E08D1204E85F3776D8853 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = BAADA69C6172DD7F4A642E93 /* Sparkle */; }; 3B5F96F9CC6D4D81B470DB2C /* EmojiSynonymCatalogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF474064973F4752F79BB041 /* EmojiSynonymCatalogTests.swift */; }; 3BB16F9F9F84C7A8D87DF044 /* es-100l.txt in Resources */ = {isa = PBXBuildFile; fileRef = 620D393D3B7E687A08FA9446 /* es-100l.txt */; }; @@ -179,6 +182,7 @@ 3D82280EFF7F7E9F3FFF45ED /* LlamaEvalScoring.swift in Sources */ = {isa = PBXBuildFile; fileRef = 906011A6C9D66EEBAF3B5CC0 /* LlamaEvalScoring.swift */; }; 3E1DBB2ABCB2404B59173534 /* SettingsIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34EF2406E7A384F3325AAF9A /* SettingsIndex.swift */; }; 3E28FFB8F7D91D3B39968E73 /* SuggestionSubsystemContracts.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEB16474A67CE1D210B944C9 /* SuggestionSubsystemContracts.swift */; }; + 3E6BD683DA505C9737463890 /* SettingsNavigationModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D1AC5EC06664F49A6AE2B17 /* SettingsNavigationModel.swift */; }; 3E78D03ABA7141D344AB8285 /* he.txt in Resources */ = {isa = PBXBuildFile; fileRef = C9C000E46A1E404932F89C81 /* he.txt */; }; 3EF0A298B5590571B1C37282 /* FieldStyleCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7FBF2B766E728F25899B64E /* FieldStyleCache.swift */; }; 3F5630CFB7BA40B900E832A1 /* OCRTextHygieneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EED3CD2BC7B48DF35DEE562 /* OCRTextHygieneTests.swift */; }; @@ -280,6 +284,7 @@ 5F78F7F686015A06725698F1 /* EmojiCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AC3BF78835C8F2C315932F1 /* EmojiCatalog.swift */; }; 6014B31E2570EFFE45557E33 /* TickMarkSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67586807ACE8EB13C9014535 /* TickMarkSlider.swift */; }; 60636D92D12FED132250D8D2 /* PerformancePaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEBD6113A3C1038BECC99245 /* PerformancePaneView.swift */; }; + 60773158D86A0D2F0EB3FF34 /* SettingsQuickLinkCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE0A565A2AD007EBE9D70697 /* SettingsQuickLinkCard.swift */; }; 6106B16C0DBA94EBF838D93E /* PermissionOverlayTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6423D6CC8CC371D2DA899DE /* PermissionOverlayTracker.swift */; }; 61635150B8004F6CB2FACE65 /* AppSurfaceClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B0830FBE4F2E239F670DBA /* AppSurfaceClassifier.swift */; }; 61EC9D635D416115E7C96E0F /* PermissionOverlayWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92C6EB9FDA48ADF425A116A9 /* PermissionOverlayWindowController.swift */; }; @@ -369,6 +374,7 @@ 84A4CA05AF6885AE4FA4C13A /* SettingsAttentionEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A02336442BB735EE2E8D064 /* SettingsAttentionEvaluator.swift */; }; 856082F4732206A3761816DC /* SystemMetricsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 38931C165873B50B405CC602 /* SystemMetricsStoreTests.swift */; }; 862146ABDADC022A3BE74E00 /* CurrencyEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0537986794554F5FABE6EFF3 /* CurrencyEvaluator.swift */; }; + 864EC22F38FB832BB96FF58B /* SettingsQuickLinkCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE0A565A2AD007EBE9D70697 /* SettingsQuickLinkCard.swift */; }; 865C569A9BC95B08F440D199 /* SystemResourceSampler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27A5D63F390E9B7A7FE343FE /* SystemResourceSampler.swift */; }; 86AC625B4DD14EF807002FA2 /* WebContentFieldDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A35FAA742408D002B75920 /* WebContentFieldDetector.swift */; }; 87806DE08881D11F2608A13D /* MarkerSelectionSynthesizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 52BAFA2F989C3C4F7FB892B5 /* MarkerSelectionSynthesizerTests.swift */; }; @@ -495,6 +501,7 @@ B849D68E0474CECAE809881C /* DebouncePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC17643448271DE5DE61A89 /* DebouncePolicy.swift */; }; B909A118616C0C47AAB6039A /* SpeculativeAcceptanceContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7EB66904C35A7D8BEF5D2A5 /* SpeculativeAcceptanceContext.swift */; }; B93AB7E845086F6FBB068369 /* SuggestionRequestFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE94342B888A5A2CCF66BC93 /* SuggestionRequestFactoryTests.swift */; }; + B95C928082728C49D851E40D /* SettingsSearchResultRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE8D7424D350FC7C0685DBEC /* SettingsSearchResultRow.swift */; }; B9623395B31459D9D45B1320 /* CurrentWordExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 247561C626843957CFB4B632 /* CurrentWordExtractor.swift */; }; B9F400BCC20757DA5DB0B5F9 /* FoundationModelSuggestionEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5664E34B23FBDF69292FEF43 /* FoundationModelSuggestionEngine.swift */; }; BA74281E2DDE659C5CACBF24 /* KeyRecorderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A567677424A82D9EEF47495 /* KeyRecorderView.swift */; }; @@ -503,6 +510,7 @@ BBBD7B4628BA1160DD6B4BDB /* MacroModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B41F06FEF208B30ECCF23A6F /* MacroModels.swift */; }; BBE22CE4EF43247F8775B25D /* FocusPollBackoff.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09FADF683BE7B3558377FA76 /* FocusPollBackoff.swift */; }; BC0FDF9998CA892F4EE0E2E2 /* SuggestionCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F961F5DF2A392F6F5F94F8A /* SuggestionCoordinator.swift */; }; + BC27A2F336857345642A30E5 /* SettingsSearchRanker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 866D74711A35E0085D2A4BB3 /* SettingsSearchRanker.swift */; }; BC33B7F6EF791AD304646655 /* SuggestionAnchorCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = E99C99D2782EAF0492F964EF /* SuggestionAnchorCache.swift */; }; BD77B0CFB09BF0B4EDB1B0C6 /* TagChip.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB317C82CE2CBC69056BA4B8 /* TagChip.swift */; }; BD94A8663D79D9609461F894 /* SurfaceContextCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CF6B7FFBF77B4290F5F2FB8 /* SurfaceContextCache.swift */; }; @@ -560,6 +568,7 @@ D2F1DD215989BF32675308C2 /* SuggestionCoordinator+Input.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22544F4B756E3E4144497D17 /* SuggestionCoordinator+Input.swift */; }; D368C9A8531677174956DE50 /* ClipboardContextProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CF4FB0EC6C1BEB4EA74910A /* ClipboardContextProvider.swift */; }; D3B43622E5A41B11E7AF527E /* TrailingDuplicationFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D408D647412C59F3E692C42B /* TrailingDuplicationFilter.swift */; }; + D3BC4EA192B234EB22361186 /* SettingsSearchRanker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 866D74711A35E0085D2A4BB3 /* SettingsSearchRanker.swift */; }; D46A0DB70B07F487431F48F6 /* EmojiPickerPanelLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7B185BA246A526CBA85E581 /* EmojiPickerPanelLayoutTests.swift */; }; D4DD6B6D22598BB3B98792DA /* AGPL-3.0.txt in Resources */ = {isa = PBXBuildFile; fileRef = 6F0EE728C0B1A7AD6B19CD0C /* AGPL-3.0.txt */; }; D553BAA6C9F478533BD4A221 /* PermissionsPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7113D3373525113CA69E7597 /* PermissionsPaneView.swift */; }; @@ -606,6 +615,7 @@ E853B9C7AF93FA595DC417B2 /* EmojiVariantResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A8414BEB7E34F57607E37FE /* EmojiVariantResolver.swift */; }; E912D4617AE1376061DF1F00 /* LanguageSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4793D4EA5D36D7E5CC216C27 /* LanguageSupportTests.swift */; }; E95888E76AA68A18A88AD8E6 /* EmojiTriggerStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 312C7306D916963F519CE0D9 /* EmojiTriggerStateMachine.swift */; }; + E98161E9592F825F9CB7D32D /* SettingsSearchResultRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE8D7424D350FC7C0685DBEC /* SettingsSearchResultRow.swift */; }; E994FE418A961FB234D9057A /* DownloadFileRescuerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2F46767D9D1F0D44E239CA8 /* DownloadFileRescuerTests.swift */; }; E9E4CC657771DF9F4C56183C /* VisualContextCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A854CAFB1F557BC4CAED8819 /* VisualContextCoordinator.swift */; }; EA89011A4D7ADD2A716D886E /* EmojiPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A28B4A4368DB33C25E3AB5F3 /* EmojiPaneView.swift */; }; @@ -620,6 +630,7 @@ EF0DE5E045F328F1E912A02A /* AppsPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C1C921A1CDA2ADFC39EA01 /* AppsPaneView.swift */; }; EF5BAB96DDADABB86F9E02D9 /* SyntheticReplacePlannerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C71031E8DB171047318B92FC /* SyntheticReplacePlannerTests.swift */; }; F04D9470439699DB1F016000 /* MenuBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AA0117B322C625F6D4BBEAB /* MenuBarView.swift */; }; + F0556D369F809445D0AC4E9C /* SettingsSearchRankerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C533B73CDDA3685135C460FB /* SettingsSearchRankerTests.swift */; }; F067EA26AC2D007382CE520F /* EmojiPickerModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E5F074ED7E340E9B9E4C5E0 /* EmojiPickerModels.swift */; }; F08C139B246C1EC7BB435455 /* MenuBarPresentationObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00824BDD8D0E9B3063827C78 /* MenuBarPresentationObserver.swift */; }; F28FB178EC507C3D42A6F893 /* SuggestionInteractionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA942A53B7C09D1F4EC57239 /* SuggestionInteractionState.swift */; }; @@ -717,6 +728,7 @@ 1C4751DFE9DA372FBC40BA30 /* CurrentWordExtractorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentWordExtractorTests.swift; sourceTree = ""; }; 1CE61E74928C221B8BB261C6 /* SuggestionTextColorCodec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionTextColorCodec.swift; sourceTree = ""; }; 1D00A031C0D9CF2A7A2330D9 /* PermissionDragSourceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionDragSourceView.swift; sourceTree = ""; }; + 1D1AC5EC06664F49A6AE2B17 /* SettingsNavigationModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsNavigationModel.swift; sourceTree = ""; }; 1E0513E3B23937B099A3CFF2 /* WordCountFormatterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WordCountFormatterTests.swift; sourceTree = ""; }; 1ED1EA9282E0AC7592E60889 /* SuggestionCoordinator+Prediction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionCoordinator+Prediction.swift"; sourceTree = ""; }; 1F761083EA5465023D82B5F4 /* BrowserDomainTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserDomainTests.swift; sourceTree = ""; }; @@ -862,6 +874,7 @@ 85BF316556FDA64CB8AD07B6 /* PermissionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionManager.swift; sourceTree = ""; }; 85EF79E6144D6C6AD062B569 /* BaseCompletionPromptRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseCompletionPromptRenderer.swift; sourceTree = ""; }; 86460C747AA883FDE756BDBA /* SuggestionSettingsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSettingsModel.swift; sourceTree = ""; }; + 866D74711A35E0085D2A4BB3 /* SettingsSearchRanker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSearchRanker.swift; sourceTree = ""; }; 8724ECA8FABBC82B0A2B943B /* FoundationModelAvailabilityService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationModelAvailabilityService.swift; sourceTree = ""; }; 87C309CD6A454C415D8BEEC7 /* SuggestionTextColorCodecTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionTextColorCodecTests.swift; sourceTree = ""; }; 8896D976C7F116EBA0F3969F /* ChromiumAccessibilityEnabler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChromiumAccessibilityEnabler.swift; sourceTree = ""; }; @@ -964,6 +977,8 @@ BC4F887528AE74AC0DD30314 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; BD42C7E2852F59BEF7972663 /* MenuBarStatusLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarStatusLabelView.swift; sourceTree = ""; }; BE04620C905041680116BE80 /* LlamaSuggestionEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LlamaSuggestionEngine.swift; sourceTree = ""; }; + BE0A565A2AD007EBE9D70697 /* SettingsQuickLinkCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsQuickLinkCard.swift; sourceTree = ""; }; + BE8D7424D350FC7C0685DBEC /* SettingsSearchResultRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSearchResultRow.swift; sourceTree = ""; }; BE97A8169438D593C6C23412 /* VisualContextModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualContextModels.swift; sourceTree = ""; }; BEF60972B2D88E4EC4841AB0 /* GPL-3.0.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "GPL-3.0.txt"; sourceTree = ""; }; BF474064973F4752F79BB041 /* EmojiSynonymCatalogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiSynonymCatalogTests.swift; sourceTree = ""; }; @@ -977,6 +992,7 @@ C3A35FAA742408D002B75920 /* WebContentFieldDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebContentFieldDetector.swift; sourceTree = ""; }; C451E144D220D5C63372A8C0 /* AppSurfaceClassifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSurfaceClassifierTests.swift; sourceTree = ""; }; C49F67B3EEB2F2A577A54085 /* DeviceInfoTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceInfoTests.swift; sourceTree = ""; }; + C533B73CDDA3685135C460FB /* SettingsSearchRankerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsSearchRankerTests.swift; sourceTree = ""; }; C602357DDED5D11C8B4567FB /* SurfaceContextComposer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceContextComposer.swift; sourceTree = ""; }; C648EBB10D7F8E0B904DEC91 /* de.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = de.txt; sourceTree = ""; }; C71031E8DB171047318B92FC /* SyntheticReplacePlannerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyntheticReplacePlannerTests.swift; sourceTree = ""; }; @@ -1026,6 +1042,7 @@ DF3A73EB848780061FC162C0 /* SpellingDictionaryPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpellingDictionaryPicker.swift; sourceTree = ""; }; E0871985CB1F877EC422E18C /* SpellingLanguageResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpellingLanguageResolverTests.swift; sourceTree = ""; }; E0D2FEEA4304C86324BAADAB /* InsertionStrategySelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InsertionStrategySelector.swift; sourceTree = ""; }; + E17AAA3C022A8AE39FACAAD5 /* SettingsIconTile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsIconTile.swift; sourceTree = ""; }; E19A5B462891263BDFB56607 /* TrailingDuplicationFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrailingDuplicationFilterTests.swift; sourceTree = ""; }; E1FAD890FBC2D0351C0E3C60 /* ContextLivePreviewField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextLivePreviewField.swift; sourceTree = ""; }; E217A66717D78E1E49350EC8 /* DownloadOutcomeClassifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadOutcomeClassifierTests.swift; sourceTree = ""; }; @@ -1272,8 +1289,11 @@ E5DAF68AEBFE334F68A65E82 /* AcceptanceModePickerView.swift */, E1FAD890FBC2D0351C0E3C60 /* ContextLivePreviewField.swift */, A594D02B0EF3C2DD62EFE69A /* GhostTextPreview.swift */, + E17AAA3C022A8AE39FACAAD5 /* SettingsIconTile.swift */, 19BE12C28A4AB8A4A58C2FF7 /* SettingsPaneScaffold.swift */, + BE0A565A2AD007EBE9D70697 /* SettingsQuickLinkCard.swift */, 907549CB913B40C28B953A5D /* SettingsRowLabel.swift */, + BE8D7424D350FC7C0685DBEC /* SettingsSearchResultRow.swift */, ); path = Components; sourceTree = ""; @@ -1473,6 +1493,7 @@ 2D7360A6D4261989A66658ED /* SentenceBoundaryClassifierTests.swift */, 2BC293F6125E2B14DCF05AD9 /* SettingsAttentionEvaluatorTests.swift */, 5A2FFC2055C52FB837DEEB8F /* SettingsIndexTests.swift */, + C533B73CDDA3685135C460FB /* SettingsSearchRankerTests.swift */, 0850B07CCDBA67C756C6EC59 /* ShortcutConflictTests.swift */, CA5B101C75C5D3972E33E8E0 /* SpeculativeAcceptanceContextTests.swift */, D562A73C7C680F2AA65F9F7F /* SpellingDictionaryResourceTests.swift */, @@ -1587,6 +1608,7 @@ 5D0AEFF86F8210CBE7CFCBAD /* SettingsCategory.swift */, DB0CE9AB1286367BA2E82392 /* SettingsContainerView.swift */, 34EF2406E7A384F3325AAF9A /* SettingsIndex.swift */, + 1D1AC5EC06664F49A6AE2B17 /* SettingsNavigationModel.swift */, BADB38D0160B47637572FC5E /* SettingsSidebarView.swift */, ); path = Settings; @@ -1696,6 +1718,7 @@ E68BE6A22BA0D42C8DD9868C /* SelfCaptureGate.swift */, D4B56C250DDEF3E81F9DCBD7 /* SentenceBoundaryClassifier.swift */, 2A02336442BB735EE2E8D064 /* SettingsAttentionEvaluator.swift */, + 866D74711A35E0085D2A4BB3 /* SettingsSearchRanker.swift */, B7EB66904C35A7D8BEF5D2A5 /* SpeculativeAcceptanceContext.swift */, 0348A7053E5683C68879A71A /* SpellingLanguageResolver.swift */, 299BD7B741DA4AAE6A061BAD /* StreamedGhostTextPolicy.swift */, @@ -2089,9 +2112,14 @@ A1A612C90221E0FE1195754A /* SettingsCategory.swift in Sources */, 1EB90D3D8A5BA028B86E4D9F /* SettingsContainerView.swift in Sources */, AD0FE3F0F75A40B827109589 /* SettingsCoordinator.swift in Sources */, + 287FC5401537014F688D92E2 /* SettingsIconTile.swift in Sources */, 3E1DBB2ABCB2404B59173534 /* SettingsIndex.swift in Sources */, + 3AFDA4A5BE9486A1B367815B /* SettingsNavigationModel.swift in Sources */, 35F6F62A299713660CFB4797 /* SettingsPaneScaffold.swift in Sources */, + 864EC22F38FB832BB96FF58B /* SettingsQuickLinkCard.swift in Sources */, 14611368270D611A2D5DC67E /* SettingsRowLabel.swift in Sources */, + BC27A2F336857345642A30E5 /* SettingsSearchRanker.swift in Sources */, + B95C928082728C49D851E40D /* SettingsSearchResultRow.swift in Sources */, 753DC144B9394A35A3F395DA /* SettingsSidebarView.swift in Sources */, 6F2FE689BCA50BEAE80AC6F4 /* ShortcutsPaneView.swift in Sources */, B909A118616C0C47AAB6039A /* SpeculativeAcceptanceContext.swift in Sources */, @@ -2322,9 +2350,14 @@ 907A0BF56C3BB0CBAF2649AB /* SettingsCategory.swift in Sources */, 2E3DEB7E89D0146274596F2E /* SettingsContainerView.swift in Sources */, 644EEF959D07D54CC779BBF6 /* SettingsCoordinator.swift in Sources */, + 0CD30EF91DFB6D95D2964C3F /* SettingsIconTile.swift in Sources */, 4E2DEFF3CA51E8B160B802CA /* SettingsIndex.swift in Sources */, + 3E6BD683DA505C9737463890 /* SettingsNavigationModel.swift in Sources */, 4B93D26BACEEA932E92B1A19 /* SettingsPaneScaffold.swift in Sources */, + 60773158D86A0D2F0EB3FF34 /* SettingsQuickLinkCard.swift in Sources */, 078FDE669437D756678E9AB7 /* SettingsRowLabel.swift in Sources */, + D3BC4EA192B234EB22361186 /* SettingsSearchRanker.swift in Sources */, + E98161E9592F825F9CB7D32D /* SettingsSearchResultRow.swift in Sources */, 27D4F5CACADE171F142178B4 /* SettingsSidebarView.swift in Sources */, 12995E5DDB11E3395E6AF82F /* ShortcutsPaneView.swift in Sources */, 4B2C52C714D04D17ACB70B99 /* SpeculativeAcceptanceContext.swift in Sources */, @@ -2495,6 +2528,7 @@ 1D1C6FF0B8F50AC14A1000F4 /* SentenceBoundaryClassifierTests.swift in Sources */, C618C5595DA9C57C806A3E03 /* SettingsAttentionEvaluatorTests.swift in Sources */, F71FD79FAC8B59C1CBD9E2E0 /* SettingsIndexTests.swift in Sources */, + F0556D369F809445D0AC4E9C /* SettingsSearchRankerTests.swift in Sources */, 8441299082E6B68F7F88911B /* ShortcutConflictTests.swift in Sources */, 14B2492F1208888C0C3F8804 /* SpeculativeAcceptanceContextTests.swift in Sources */, 303652F15C0FE55595669D81 /* SpellingDictionaryResourceTests.swift in Sources */, diff --git a/Cotabby/App/Coordinators/SettingsCoordinator.swift b/Cotabby/App/Coordinators/SettingsCoordinator.swift index 092b54a4..0f50ef2b 100644 --- a/Cotabby/App/Coordinators/SettingsCoordinator.swift +++ b/Cotabby/App/Coordinators/SettingsCoordinator.swift @@ -86,13 +86,14 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate { ) ) ) - // Sized so the native split view opens with a readable sidebar and a comfortable grouped - // detail form. The user can still resize from here; the sidebar provides its own range. - let initialFrame = CGRect(x: 0, y: 0, width: 980, height: 700) + // Sized so the native split view opens with a readable sidebar, a comfortable grouped + // detail form, and room for the Home pane's status-card row to breathe. The user can still + // resize from here; the sidebar provides its own range. + let initialFrame = CGRect(x: 0, y: 0, width: 1060, height: 720) let minSize = NSSize(width: 900, height: 560) // Bump the autosave name to reset everyone onto the current default instead of restoring - // any narrower frame saved by the previous sidebar experiments. - let autosaveName = "CotabbySettingsWindowV6" + // any narrower frame saved before the Home redesign. + let autosaveName = "CotabbySettingsWindowV7" let window = NSWindow( contentRect: initialFrame, diff --git a/Cotabby/App/Core/AppDelegate.swift b/Cotabby/App/Core/AppDelegate.swift index adc68a09..74536883 100644 --- a/Cotabby/App/Core/AppDelegate.swift +++ b/Cotabby/App/Core/AppDelegate.swift @@ -140,6 +140,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate { welcomeCoordinator.presentPermissionReminderIfNeeded() didStartServices = true CotabbyLogger.app.info("All services started") + + // Dev affordance in the spirit of `-cotabby-debug`: a menu-bar-only app has no scriptable + // path to its Settings window (the status item is unreachable from AppleScript), so UI + // work on Settings cannot be exercised by tooling without this. No-op unless passed. + if ProcessInfo.processInfo.arguments.contains("-cotabby-open-settings") { + settingsCoordinator.showSettings() + } } /// One-time default: enable Open at Login for every user (new and existing) the first time this diff --git a/Cotabby/Support/SettingsSearchRanker.swift b/Cotabby/Support/SettingsSearchRanker.swift new file mode 100644 index 00000000..97d8a958 --- /dev/null +++ b/Cotabby/Support/SettingsSearchRanker.swift @@ -0,0 +1,213 @@ +import Foundation + +/// File overview: +/// Pure relevance ranking for Settings search. The old search was a flat `contains` filter in +/// declaration order, which made common queries feel arbitrary: "ghost" listed whichever item +/// happened to be declared first, a typo found nothing, and multi-word queries only matched when +/// one field contained the whole phrase. This ranker scores every item per query token across its +/// title, keywords, owning pane, and summary, so results come back ordered by how directly they +/// answer the query. +/// +/// Lives in `Support/` as a pure rule: no SwiftUI, no app state, fully unit-testable. The UI layer +/// conforms its catalog type (`SettingsItem`) to `SettingsSearchable` and calls `rank`. +/// +/// Scoring model, per query token (highest applicable tier wins per field, best field wins per +/// token): +/// - Title: exact > prefix > word prefix > substring > fuzzy subsequence. +/// - Keywords: same tiers, weighted below title so synonyms help without outranking direct hits. +/// - Pane label: lets "emoji" surface the whole Emoji pane's items. +/// - Summary: catches descriptive phrasing ("too big", "on every keystroke"). +/// An item matches only when every token matches somewhere; token scores then sum, with a small +/// cohesion bonus when all tokens hit the title. Ties keep declaration order so results stay stable. +enum SettingsSearchRanker { + /// One scored item, exposed for tests and for callers that want to inspect relevance. + struct Match { + let item: Item + let score: Double + } + + /// Items matching `query`, best first. Empty for a blank query. + static func rank(_ query: String, in items: [Item]) -> [Item] { + matches(query, in: items).map(\.item) + } + + /// Scored matches for `query`, best first. Empty for a blank query. + static func matches(_ query: String, in items: [Item]) -> [Match] { + let tokens = tokenize(query) + guard !tokens.isEmpty else { return [] } + + let joinedQuery = tokens.joined(separator: " ") + let scored: [(offset: Int, match: Match)] = items.enumerated().compactMap { offset, item in + guard let score = score(tokens: tokens, joinedQuery: joinedQuery, item: item) else { return nil } + return (offset, Match(item: item, score: score)) + } + + return scored + .sorted { lhs, rhs in + if lhs.match.score != rhs.match.score { + return lhs.match.score > rhs.match.score + } + return lhs.offset < rhs.offset + } + .map(\.match) + } + + // MARK: - Scoring + + /// Tier weights for one searchable field. `nil` disables a tier for that field. + private struct FieldWeights { + let exact: Double + let prefix: Double + let wordPrefix: Double + let substring: Double + let subsequence: Double? + } + + private static let titleWeights = FieldWeights( + exact: 100, prefix: 90, wordPrefix: 80, substring: 60, subsequence: 25 + ) + private static let keywordWeights = FieldWeights( + exact: 70, prefix: 55, wordPrefix: 50, substring: 40, subsequence: 12 + ) + private static let groupWeights = FieldWeights( + exact: 35, prefix: 30, wordPrefix: 25, substring: 20, subsequence: nil + ) + private static let summaryWeights = FieldWeights( + exact: 30, prefix: 30, wordPrefix: 30, substring: 18, subsequence: nil + ) + + /// Bonus when every query token lands in the title: "ghost size" should place + /// "Ghost Text Size" above items where the tokens are split across unrelated fields. + private static let fullTitleCohesionBonus: Double = 15 + + /// Bonus when the whole query IS the title. Per-token scoring alone can tie a short title + /// with a longer one that contains the same words ("Accept Word" vs "Accept Punctuation With + /// Word"); typing a row's exact name must always win. + private static let exactTitleBonus: Double = 40 + + private static func score( + tokens: [String], + joinedQuery: String, + item: some SettingsSearchable + ) -> Double? { + let title = normalize(item.searchTitle) + let keywords = item.searchKeywords.map(normalize) + let group = normalize(item.searchGroupLabel) + let summary = normalize(item.searchSummary) + + var total = 0.0 + var titleHits = 0 + + for token in tokens { + var best = 0.0 + var tokenHitTitle = false + + if let titleScore = fieldScore(token: token, target: title, weights: titleWeights) { + best = titleScore + tokenHitTitle = true + } + for keyword in keywords { + if let keywordScore = fieldScore(token: token, target: keyword, weights: keywordWeights), + keywordScore > best { + best = keywordScore + tokenHitTitle = false + } + } + if let groupScore = fieldScore(token: token, target: group, weights: groupWeights), + groupScore > best { + best = groupScore + tokenHitTitle = false + } + if let summaryScore = fieldScore(token: token, target: summary, weights: summaryWeights), + summaryScore > best { + best = summaryScore + tokenHitTitle = false + } + + guard best > 0 else { return nil } + total += best + if tokenHitTitle { titleHits += 1 } + } + + if titleHits == tokens.count { + total += fullTitleCohesionBonus + } + if title == joinedQuery { + total += exactTitleBonus + } + return total + } + + private static func fieldScore(token: String, target: String, weights: FieldWeights) -> Double? { + guard !target.isEmpty else { return nil } + if target == token { return weights.exact } + if target.hasPrefix(token) { return weights.prefix } + // Reverse prefix: the user typed past the target ("languages" vs the keyword "language", + // "screenshots" vs "screenshot"). Both sides must be substantial so a long token does not + // match every tiny word it happens to start with. + if token.count >= 4, target.count >= 4, token.hasPrefix(target) { return weights.prefix } + if words(in: target).contains(where: { word in + word.hasPrefix(token) || (token.count >= 4 && word.count >= 4 && token.hasPrefix(word)) + }) { + return weights.wordPrefix + } + if target.contains(token) { return weights.substring } + // Subsequence matching is the typo net ("batery" -> "battery"). Short tokens are skipped: + // two letters are a subsequence of almost everything and would flood results with noise. + if let subsequenceWeight = weights.subsequence, + token.count >= 3, + isSubsequence(token, of: target) { + return subsequenceWeight + } + return nil + } + + // MARK: - Text helpers + + /// Lowercased, diacritic-folded comparison form so "café" and "cafe" meet in the middle. + private static func normalize(_ text: String) -> String { + text.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: .current) + .lowercased() + } + + private static func words(in text: String) -> [String] { + text.split(whereSeparator: { !$0.isLetter && !$0.isNumber }).map(String.init) + } + + /// Query tokens: normalized words, capped so a pathological paste cannot turn scoring into + /// quadratic work across the catalog. + private static func tokenize(_ query: String) -> [String] { + let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return [] } + return trimmed + .split(whereSeparator: { $0.isWhitespace }) + .prefix(8) + .map { normalize(String($0)) } + .filter { !$0.isEmpty } + } + + /// Two-pointer subsequence test: every character of `token` appears in `target` in order. + private static func isSubsequence(_ token: String, of target: String) -> Bool { + var tokenIndex = token.startIndex + for character in target { + guard tokenIndex < token.endIndex else { return true } + if token[tokenIndex] == character { + tokenIndex = token.index(after: tokenIndex) + } + } + return tokenIndex == token.endIndex + } +} + +/// What the ranker needs to know about one searchable setting. Kept as a protocol so the pure +/// ranker never imports the UI catalog type that conforms to it. +protocol SettingsSearchable { + /// The row's visible title ("Ghost Text Size"). + var searchTitle: String { get } + /// Synonyms and adjacent vocabulary a user might type instead of the title. + var searchKeywords: [String] { get } + /// The owning pane's label ("Appearance"), so pane-name queries surface its items. + var searchGroupLabel: String { get } + /// The one-line caption shown under the row, searched for descriptive phrasing. + var searchSummary: String { get } +} diff --git a/Cotabby/UI/Settings/Components/SettingsIconTile.swift b/Cotabby/UI/Settings/Components/SettingsIconTile.swift new file mode 100644 index 00000000..f9d7d503 --- /dev/null +++ b/Cotabby/UI/Settings/Components/SettingsIconTile.swift @@ -0,0 +1,39 @@ +import SwiftUI + +/// File overview: +/// The System Settings-style icon tile: a white SF Symbol on a tinted, continuously rounded +/// square with a soft top-to-bottom gradient. One component drawn at three scales keeps the +/// sidebar, search results, and Home quick links visually related, so a category reads as the +/// same object everywhere it appears. +struct SettingsIconTile: View { + let systemImage: String + let tint: Color + /// Edge length of the tile. The symbol and corner radius scale from it so callers only + /// choose a size, never a matching radius/font pair. + var size: CGFloat = 22 + + var body: some View { + Image(systemName: systemImage) + .font(.system(size: size * 0.52, weight: .medium)) + .foregroundStyle(.white) + // White symbols disappear into pale tints (yellow especially) without a touch of + // depth; the hairline shadow keeps the glyph legible on every tile color. + .shadow(color: .black.opacity(0.15), radius: 0.5, y: 0.5) + .frame(width: size, height: size) + .background( + RoundedRectangle(cornerRadius: size * 0.24, style: .continuous) + .fill( + LinearGradient( + colors: [tint.opacity(0.85), tint], + startPoint: .top, + endPoint: .bottom + ) + ) + ) + .overlay( + RoundedRectangle(cornerRadius: size * 0.24, style: .continuous) + .strokeBorder(.white.opacity(0.12), lineWidth: 0.5) + ) + .accessibilityHidden(true) + } +} diff --git a/Cotabby/UI/Settings/Components/SettingsPaneScaffold.swift b/Cotabby/UI/Settings/Components/SettingsPaneScaffold.swift index e7fd044b..f3807bfe 100644 --- a/Cotabby/UI/Settings/Components/SettingsPaneScaffold.swift +++ b/Cotabby/UI/Settings/Components/SettingsPaneScaffold.swift @@ -10,10 +10,17 @@ import SwiftUI /// surfaces attention per pane: when a pane is in a degraded state (missing permission, runtime /// unavailable) we render an inline callout above the form so the actionable surface lives next to /// the controls that fix it. +/// +/// Search arrival: +/// When search reveals a specific setting, the scaffold scrolls to the row carrying the matching +/// `.settingsItem(_:)` anchor. The row's own modifier renders the pulse; the scaffold only owns +/// the scroll, so panes stay declarative. struct SettingsPaneScaffold: View { let callout: SettingsPaneCallout? @ViewBuilder let content: () -> Content + @Environment(\.settingsHighlightedItem) private var highlightedItem + init( callout: SettingsPaneCallout? = nil, @ViewBuilder content: @escaping () -> Content @@ -23,22 +30,44 @@ struct SettingsPaneScaffold: View { } var body: some View { - ScrollView { - VStack(spacing: 0) { - if let callout { - SettingsCalloutView(callout: callout) - .padding(.horizontal, 20) - .padding(.top, 16) + ScrollViewReader { proxy in + ScrollView { + VStack(spacing: 0) { + if let callout { + SettingsCalloutView(callout: callout) + .padding(.horizontal, 20) + .padding(.top, 16) + } + Form { + content() + } + .formStyle(.grouped) + // `.formStyle(.grouped)` only pads BEFORE a `Section` that has a header. Panes + // whose first section is header-less (General, About, Apps) would otherwise butt + // flush against the title bar. A fixed top inset gives every pane the same + // breathing room regardless of whether the first section carries a header. + .padding(.top, 12) } - Form { - content() + } + .onAppear { + // The pane is rebuilt on every sidebar switch (`.id(selection)` in the container), + // so a search arrival lands here before rows have laid out. A one-beat delay lets + // the form settle so `scrollTo` has a real geometry target. + guard let item = highlightedItem else { return } + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(80)) + withAnimation(.easeInOut(duration: 0.35)) { + proxy.scrollTo(item, anchor: .center) + } + } + } + .onChange(of: highlightedItem) { _, item in + // Same-pane reveals (a second search while already on the pane) skip onAppear, so + // the scroll also rides the highlight change itself. + guard let item else { return } + withAnimation(.easeInOut(duration: 0.35)) { + proxy.scrollTo(item, anchor: .center) } - .formStyle(.grouped) - // `.formStyle(.grouped)` only pads BEFORE a `Section` that has a header. Panes - // whose first section is header-less (General, About, Apps) would otherwise butt - // flush against the title bar. A fixed top inset gives every pane the same - // breathing room regardless of whether the first section carries a header. - .padding(.top, 12) } } } diff --git a/Cotabby/UI/Settings/Components/SettingsQuickLinkCard.swift b/Cotabby/UI/Settings/Components/SettingsQuickLinkCard.swift new file mode 100644 index 00000000..8c4ca970 --- /dev/null +++ b/Cotabby/UI/Settings/Components/SettingsQuickLinkCard.swift @@ -0,0 +1,67 @@ +import SwiftUI + +/// File overview: +/// One Home quick-link card: an icon tile, a title, a one-line caption, and a chevron that walks +/// in on hover. The whole card is a button that opens its pane. Hover lift is kept subtle (a +/// hairline tint and a 1pt rise, no scaling) so a grid of six never feels like a game menu. +struct SettingsQuickLinkCard: View { + let category: SettingsCategory + let action: () -> Void + + @State private var isHovering = false + + var body: some View { + Button(action: action) { + HStack(spacing: 12) { + SettingsIconTile(systemImage: category.systemImage, tint: category.tint, size: 34) + + VStack(alignment: .leading, spacing: 2) { + Text(category.label) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.primary) + Text(category.summary) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer(minLength: 4) + + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.tertiary) + .opacity(isHovering ? 1 : 0) + .offset(x: isHovering ? 0 : -4) + } + .padding(.horizontal, 14) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(.regularMaterial) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder( + isHovering ? category.tint.opacity(0.35) : Color.primary.opacity(0.07), + lineWidth: 1 + ) + ) + .shadow( + color: .black.opacity(isHovering ? 0.10 : 0.04), + radius: isHovering ? 6 : 2, + y: isHovering ? 3 : 1 + ) + .offset(y: isHovering ? -1 : 0) + .contentShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + .buttonStyle(.plain) + .onHover { hovering in + withAnimation(.easeOut(duration: 0.15)) { + isHovering = hovering + } + } + .accessibilityLabel("\(category.label) settings") + .accessibilityHint(category.summary) + } +} diff --git a/Cotabby/UI/Settings/Components/SettingsSearchResultRow.swift b/Cotabby/UI/Settings/Components/SettingsSearchResultRow.swift new file mode 100644 index 00000000..9df5eea4 --- /dev/null +++ b/Cotabby/UI/Settings/Components/SettingsSearchResultRow.swift @@ -0,0 +1,65 @@ +import SwiftUI + +/// File overview: +/// One settings search hit, shared by the sidebar's result list (compact) and the Home hero +/// search (full, with summary and pane breadcrumb). The icon tile carries the destination pane's +/// tint with the item's own symbol, so a result simultaneously says what it is and where it lives. +struct SettingsSearchResultRow: View { + enum Style { + /// Single line for the narrow sidebar: tile, title, pane name. + case compact + /// Two lines for the Home hero search: tile, title plus summary, trailing pane chip. + case full + } + + let item: SettingsItem + var style: Style = .compact + + var body: some View { + HStack(spacing: 10) { + SettingsIconTile( + systemImage: item.systemImage, + tint: item.category.tint, + size: style == .full ? 28 : 20 + ) + + VStack(alignment: .leading, spacing: 1) { + Text(item.title) + .lineLimit(1) + if style == .full { + Text(item.summary) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + + Spacer(minLength: 8) + + if style == .full { + paneChip + } else { + Text(item.category.label) + .font(.caption) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + } + .contentShape(Rectangle()) + .accessibilityElement(children: .combine) + .accessibilityLabel("\(item.title), in \(item.category.label)") + } + + private var paneChip: some View { + HStack(spacing: 3) { + Text(item.category.label) + Image(systemName: "chevron.right") + .font(.system(size: 8, weight: .semibold)) + } + .font(.caption) + .foregroundStyle(.secondary) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(.quaternary.opacity(0.6), in: Capsule()) + } +} diff --git a/Cotabby/UI/Settings/Panes/AboutPaneView.swift b/Cotabby/UI/Settings/Panes/AboutPaneView.swift index bb4f8357..2f345289 100644 --- a/Cotabby/UI/Settings/Panes/AboutPaneView.swift +++ b/Cotabby/UI/Settings/Panes/AboutPaneView.swift @@ -12,10 +12,10 @@ struct AboutPaneView: View { var body: some View { SettingsPaneScaffold { - Section { aboutHeader } - Section("Support") { supportRow } - Section("Resources") { linksRow } - Section("Uninstall") { uninstallText } + Section { aboutHeader.settingsItem(.checkForUpdates) } + Section("Support") { supportRow.settingsItem(.support) } + Section("Resources") { resourceRows } + Section("Uninstall") { uninstallText.settingsItem(.uninstall) } } .sheet(isPresented: $isShowingAcknowledgements) { AcknowledgementsView { isShowingAcknowledgements = false } @@ -86,26 +86,29 @@ struct AboutPaneView: View { } } + /// One row per resource (rather than one stacked row) so each link is a separate form row that + /// search can scroll to and pulse individually. @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") - } + private var resourceRows: some View { + if let repoURL = URL(string: "https://github.com/FuJacob/Cotabby") { + Link(destination: repoURL) { + Label("GitHub Repository", systemImage: "chevron.left.forwardslash.chevron.right") } - Button { - isShowingAcknowledgements = true - } label: { - Label("Acknowledgements", systemImage: "doc.text") + .settingsItem(.githubRepository) + } + if let wikiURL = URL(string: "https://github.com/FuJacob/Cotabby/wiki") { + Link(destination: wikiURL) { + Label("Wiki & Contributor Guide", systemImage: "book") } - .buttonStyle(.link) + .settingsItem(.wiki) + } + Button { + isShowingAcknowledgements = true + } label: { + Label("Acknowledgements", systemImage: "doc.text") } + .buttonStyle(.link) + .settingsItem(.acknowledgements) } @ViewBuilder diff --git a/Cotabby/UI/Settings/Panes/AppearancePaneView.swift b/Cotabby/UI/Settings/Panes/AppearancePaneView.swift index d201c125..b65152ae 100644 --- a/Cotabby/UI/Settings/Panes/AppearancePaneView.swift +++ b/Cotabby/UI/Settings/Panes/AppearancePaneView.swift @@ -26,6 +26,7 @@ struct AppearancePaneView: View { ) } .pickerStyle(.menu) + .settingsItem(.suggestionDisplay) Toggle(isOn: streamWhileGeneratingBinding) { SettingsRowLabel( @@ -35,6 +36,7 @@ struct AppearancePaneView: View { systemImage: "text.append" ) } + .settingsItem(.streamWhileGenerating) Toggle(isOn: showIndicatorBinding) { SettingsRowLabel( @@ -43,6 +45,7 @@ struct AppearancePaneView: View { systemImage: "dot.viewfinder" ) } + .settingsItem(.showFieldIndicator) Toggle(isOn: menuBarWordCountVisibleBinding) { SettingsRowLabel( @@ -51,6 +54,7 @@ struct AppearancePaneView: View { systemImage: "number" ) } + .settingsItem(.showWordCount) Toggle(isOn: showAcceptanceHintBinding) { HStack(alignment: .firstTextBaseline, spacing: 10) { @@ -78,6 +82,7 @@ struct AppearancePaneView: View { } } } + .settingsItem(.showKeyHint) } Section("Appearance") { @@ -100,6 +105,7 @@ struct AppearancePaneView: View { systemImage: "paintpalette" ) } + .settingsItem(.ghostTextColor) LabeledContent { HStack(spacing: 10) { @@ -124,6 +130,7 @@ struct AppearancePaneView: View { systemImage: "circle.lefthalf.filled" ) } + .settingsItem(.ghostTextOpacity) LabeledContent { HStack(spacing: 10) { @@ -148,6 +155,7 @@ struct AppearancePaneView: View { systemImage: "textformat.size" ) } + .settingsItem(.ghostTextSize) } } } diff --git a/Cotabby/UI/Settings/Panes/AppsPaneView.swift b/Cotabby/UI/Settings/Panes/AppsPaneView.swift index f39954d1..6cf02411 100644 --- a/Cotabby/UI/Settings/Panes/AppsPaneView.swift +++ b/Cotabby/UI/Settings/Panes/AppsPaneView.swift @@ -22,6 +22,7 @@ struct AppsPaneView: View { + "menu bar, like a launcher that closes the moment it loses focus.") .font(.caption) .foregroundStyle(.secondary) + .settingsItem(.disabledApps) if suggestionSettings.disabledAppRules.isEmpty { Text("No apps are disabled. Cotabby is active in every supported field.") @@ -48,6 +49,7 @@ struct AppsPaneView: View { systemImage: "terminal" ) } + .settingsItem(.suggestInIntegratedTerminals) } if !filteredRunningAppSuggestions.isEmpty { diff --git a/Cotabby/UI/Settings/Panes/ContextPaneView.swift b/Cotabby/UI/Settings/Panes/ContextPaneView.swift index abe9b9b4..6802c837 100644 --- a/Cotabby/UI/Settings/Panes/ContextPaneView.swift +++ b/Cotabby/UI/Settings/Panes/ContextPaneView.swift @@ -72,6 +72,7 @@ struct ContextPaneView: View { .foregroundStyle(.tertiary) } .padding(.vertical, 6) + .settingsItem(.contextLivePreview) } } @@ -127,6 +128,7 @@ struct ContextPaneView: View { } } .padding(.vertical, 6) + .settingsItem(.extendedContext) } } diff --git a/Cotabby/UI/Settings/Panes/EmojiPaneView.swift b/Cotabby/UI/Settings/Panes/EmojiPaneView.swift index 15d840fa..47d8248d 100644 --- a/Cotabby/UI/Settings/Panes/EmojiPaneView.swift +++ b/Cotabby/UI/Settings/Panes/EmojiPaneView.swift @@ -19,6 +19,7 @@ struct EmojiPaneView: View { systemImage: "face.smiling" ) } + .settingsItem(.emojiPicker) } if suggestionSettings.isEmojiPickerEnabled { @@ -37,6 +38,7 @@ struct EmojiPaneView: View { systemImage: "hand.raised.fingers.spread" ) } + .settingsItem(.emojiSkinTone) LabeledContent { HStack(spacing: 8) { @@ -51,6 +53,7 @@ struct EmojiPaneView: View { systemImage: "person.2" ) } + .settingsItem(.emojiPeopleStyle) LabeledContent { Button("Clear History") { @@ -63,6 +66,7 @@ struct EmojiPaneView: View { systemImage: "clock.arrow.circlepath" ) } + .settingsItem(.emojiHistory) } } } diff --git a/Cotabby/UI/Settings/Panes/EngineAndModelPaneView.swift b/Cotabby/UI/Settings/Panes/EngineAndModelPaneView.swift index f94ec1b3..818e64d7 100644 --- a/Cotabby/UI/Settings/Panes/EngineAndModelPaneView.swift +++ b/Cotabby/UI/Settings/Panes/EngineAndModelPaneView.swift @@ -39,6 +39,7 @@ struct EngineAndModelPaneView: View { ) } .pickerStyle(.menu) + .settingsItem(.engine) } powerSection @@ -101,6 +102,7 @@ struct EngineAndModelPaneView: View { systemImage: "battery.100.bolt" ) } + .settingsItem(.powerBasedModelSwitching) if suggestionSettings.isPowerBasedModelSwitchingEnabled { powerProfilePicker( @@ -108,12 +110,14 @@ struct EngineAndModelPaneView: View { systemImage: "battery.25", selection: batteryProfileBinding ) + .settingsItem(.batteryModel) powerProfilePicker( title: "Plugged In", systemImage: "powerplug", selection: pluggedInProfileBinding ) + .settingsItem(.pluggedInModel) } } } @@ -186,6 +190,7 @@ struct EngineAndModelPaneView: View { systemImage: "apple.logo" ) } + .settingsItem(.appleIntelligenceAvailability) } } @@ -207,6 +212,7 @@ struct EngineAndModelPaneView: View { systemImage: "info.circle" ) } + .settingsItem(.modelStatus) } Section("Models") { @@ -237,18 +243,21 @@ struct EngineAndModelPaneView: View { // per-source profile pickers are the source of truth, and any pick here would be // reverted on the next power evaluation. .disabled(suggestionSettings.isPowerBasedModelSwitchingEnabled) + .settingsItem(.selectedModel) } DownloadableModelCatalogView( modelDownloadManager: modelDownloadManager, onRefreshModels: refreshModels ) + .settingsItem(.downloadModels) HuggingFaceModelBrowserView( searchService: huggingFaceSearchService, modelDownloadManager: modelDownloadManager, onRefreshModels: refreshModels ) + .settingsItem(.huggingFaceBrowser) } Section("Folder") { @@ -276,6 +285,7 @@ struct EngineAndModelPaneView: View { systemImage: "folder" ) } + .settingsItem(.modelsFolder) Toggle(isOn: $lmStudioSourceEnabled) { SettingsRowLabel( @@ -292,6 +302,7 @@ struct EngineAndModelPaneView: View { modelDownloadManager.refreshSearchDirectories() refreshModels() } + .settingsItem(.lmStudio) } if !runtimeModel.availableModels.isEmpty { diff --git a/Cotabby/UI/Settings/Panes/GeneralPaneView.swift b/Cotabby/UI/Settings/Panes/GeneralPaneView.swift index 3c161a3e..b95c4690 100644 --- a/Cotabby/UI/Settings/Panes/GeneralPaneView.swift +++ b/Cotabby/UI/Settings/Panes/GeneralPaneView.swift @@ -13,8 +13,6 @@ struct GeneralPaneView: View { var body: some View { SettingsPaneScaffold { - supportSection - Section("Status") { Toggle(isOn: globallyEnabledBinding) { SettingsRowLabel( @@ -23,6 +21,7 @@ struct GeneralPaneView: View { systemImage: "power" ) } + .settingsItem(.enableGlobally) Toggle(isOn: fastModeForcedOn ? .constant(true) : fastModeEnabledBinding) { SettingsRowLabel( @@ -32,6 +31,7 @@ struct GeneralPaneView: View { ) } .disabled(fastModeForcedOn) + .settingsItem(.fastMode) // Backed by `SMAppService.mainApp` via the LaunchAtLogin package, which owns the // observable for the login-item status and refreshes the toggle if the user changes @@ -43,6 +43,7 @@ struct GeneralPaneView: View { systemImage: "arrow.right.circle" ) } + .settingsItem(.openAtLogin) } // Split from the old catch-all "Behavior" group: what the model is allowed to read @@ -57,6 +58,7 @@ struct GeneralPaneView: View { systemImage: "doc.on.clipboard" ) } + .settingsItem(.includeClipboardContext) Toggle(isOn: surfaceContextEnabledBinding) { SettingsRowLabel( @@ -65,6 +67,7 @@ struct GeneralPaneView: View { systemImage: "macwindow" ) } + .settingsItem(.includeAppContext) } Section("Suggestions") { @@ -75,6 +78,7 @@ struct GeneralPaneView: View { systemImage: "text.alignleft" ) } + .settingsItem(.allowMultiLine) Toggle(isOn: macroExpansionEnabledBinding) { SettingsRowLabel( @@ -85,6 +89,7 @@ struct GeneralPaneView: View { systemImage: "slash.circle" ) } + .settingsItem(.inlineMacros) } Section("Help") { @@ -99,41 +104,12 @@ struct GeneralPaneView: View { systemImage: "graduationcap" ) } + .settingsItem(.onboarding) } } } - // MARK: - Support - - /// Pinned at the top of General so the support pitch is the first thing in the pane. The action - /// is a filled pill (white background, pink "heart Support" text) so it reads as a real button to - /// tap rather than an easy-to-miss inline link. - @ViewBuilder - private var supportSection: some View { - if let kofiURL = URL(string: "https://ko-fi.com/cotabby") { - Section { - LabeledContent { - Link(destination: kofiURL) { - Label("Support", systemImage: "heart.fill") - .font(.callout.weight(.semibold)) - .foregroundStyle(.pink) - .padding(.horizontal, 14) - .padding(.vertical, 6) - .background(.white, in: Capsule()) - } - .buttonStyle(.plain) - } label: { - SettingsRowLabel( - title: "Support Cotabby", - description: "Cotabby is free and open source. Tips help fund development.", - systemImage: "heart" - ) - } - } - } - } - // MARK: - Bindings private var globallyEnabledBinding: Binding { diff --git a/Cotabby/UI/Settings/Panes/HomePaneView.swift b/Cotabby/UI/Settings/Panes/HomePaneView.swift index a31c1b1a..4beb9361 100644 --- a/Cotabby/UI/Settings/Panes/HomePaneView.swift +++ b/Cotabby/UI/Settings/Panes/HomePaneView.swift @@ -1,70 +1,507 @@ import SwiftUI /// File overview: -/// "Home" detail pane: the welcoming landing surface of the Settings window and the first sidebar -/// row. It introduces what Cotabby is, replays the same inline-autocomplete and inline-emoji demos -/// shown on the final onboarding screen (`OnboardingFeatureShowcase`), and surfaces the Support -/// Cotabby call to action. It is the default pane on a fresh install; returning users still land on -/// their last-viewed pane. +/// "Home" detail pane: the landing surface of the Settings window. Unlike every other pane it is +/// not a grouped form; it is a composed page: an identity hero, a prominent search field over the +/// whole settings catalog, an at-a-glance status row (power, engine, permissions), quick links +/// into the most-visited panes, the live feature demos, and a one-line footer with the project +/// links. Search results replace the page body while a query is active so the field behaves like +/// a command surface rather than a filter bolted onto a page. /// -/// The feature demos are inert (they never touch the real suggestion pipeline). They are passed -/// `autoplay: false` here so the looping animations stay idle on a static frame until the pointer is -/// over them, keeping this pane cheap to leave open. (Onboarding uses the default autoplay.) +/// The feature demos are inert (they never touch the real suggestion pipeline) and are passed +/// `autoplay: false` so the looping animations stay idle until the pointer is over them, keeping +/// this pane cheap to leave open. struct HomePaneView: View { + @ObservedObject var navigation: SettingsNavigationModel + @ObservedObject var suggestionSettings: SuggestionSettingsModel + @ObservedObject var permissionManager: PermissionManager + @ObservedObject var foundationModelAvailabilityService: FoundationModelAvailabilityService + @ObservedObject var runtimeModel: RuntimeBootstrapModel + let attentionCategories: Set + + @State private var query = "" + @FocusState private var isSearchFocused: Bool + @Environment(\.colorScheme) private var colorScheme + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var hasAppeared = false + + /// The panes offered as quick links. Power, engine, and permissions already live in the + /// status row above, so the grid covers the everyday customization surfaces. + private static let quickLinkCategories: [SettingsCategory] = [ + .appearance, .writing, .shortcuts, .emoji, .apps, .performance + ] + + private static let maximumSearchResults = 12 + var body: some View { - SettingsPaneScaffold { - Section { introHeader } - Section("Support") { supportRow } - Section("See it in action") { - OnboardingFeatureShowcase(autoplay: false, showsMacroReference: true) + ZStack(alignment: .top) { + heroBackdrop + ScrollView { + VStack(spacing: 28) { + hero + searchField + + if trimmedQuery.isEmpty { + statusRow + quickLinksSection + showcaseSection + footer + } else { + searchResultsCard + } + } + .padding(.horizontal, 28) + .padding(.top, 28) + .padding(.bottom, 32) + .frame(maxWidth: 700) + .frame(maxWidth: .infinity) } } + .onAppear { + guard !hasAppeared else { return } + if reduceMotion { + hasAppeared = true + } else { + withAnimation(.spring(response: 0.5, dampingFraction: 0.8)) { + hasAppeared = true + } + } + } + // Cmd-F routes here from the container. `initial: true` covers the cross-pane case where + // Home is rebuilt because of the shortcut and the publish happens before this view exists. + .onChange(of: navigation.pendingSearchFocus, initial: true) { _, pending in + guard pending else { return } + isSearchFocused = true + Task { navigation.consumeSearchFocusRequest() } + } + } + + private var trimmedQuery: String { + query.trimmingCharacters(in: .whitespacesAndNewlines) + } + + // MARK: - Hero + + /// A soft accent wash behind the top of the page. Static (it does not scroll) so it reads as + /// room lighting rather than content. + private var heroBackdrop: some View { + LinearGradient( + colors: [Color.accentColor.opacity(0.12), Color.accentColor.opacity(0)], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 260) + .frame(maxWidth: .infinity) + .allowsHitTesting(false) + .accessibilityHidden(true) } - @ViewBuilder - private var introHeader: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 12) { - Image("CotabbyLogo") - .resizable() - .scaledToFit() - .frame(width: 44, height: 44) - .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) + private var hero: some View { + VStack(spacing: 10) { + Image("CotabbyLogo") + .resizable() + .scaledToFit() + .frame(width: 64, height: 64) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + .shadow(color: .black.opacity(0.18), radius: 8, y: 4) + .scaleEffect(hasAppeared ? 1 : 0.9) + .opacity(hasAppeared ? 1 : 0) - VStack(alignment: .leading, spacing: 2) { - Text("Welcome to Cotabby") - .font(.system(size: 17, weight: .semibold, design: .rounded)) - Text("Local-first AI autocomplete for macOS") - .font(.system(size: 12, design: .rounded)) + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text("Cotabby") + .font(.system(size: 26, weight: .bold, design: .rounded)) + if let version = appVersionText { + Text(version) + .font(.caption.weight(.medium)) .foregroundStyle(.secondary) + .padding(.horizontal, 7) + .padding(.vertical, 2) + .background(.quaternary.opacity(0.7), in: Capsule()) } } - Text("Ghost-text suggestions in any field, accepted with Tab. Everything runs on your device.") - .font(.callout) + // The tagline demos the product in one line: the trailing half renders like the ghost + // text Cotabby draws at the caret. + (Text("Write faster, ").foregroundColor(.primary) + + Text("everywhere you type").foregroundColor(ghostTextColor)) + .font(.system(size: 15, design: .rounded)) + } + .frame(maxWidth: .infinity) + .accessibilityElement(children: .combine) + } + + /// Matches the overlay's adaptive ghost gray so the tagline previews the real feature color. + private var ghostTextColor: Color { + colorScheme == .dark ? Color(white: 0.65) : Color(white: 0.45) + } + + private var appVersionText: String? { + guard let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String, + !shortVersion.isEmpty else { + return nil + } + return "v\(shortVersion)" + } + + // MARK: - Search + + private var searchField: some View { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.system(size: 14, weight: .medium)) .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) + .accessibilityHidden(true) + + TextField("Search every setting", text: $query) + .textFieldStyle(.plain) + .font(.system(size: 14)) + .focused($isSearchFocused) + .onSubmit(openTopResult) + + if trimmedQuery.isEmpty { + Text("⌘F") + .font(.caption.weight(.medium)) + .foregroundStyle(.tertiary) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 5, style: .continuous)) + .accessibilityHidden(true) + } else { + Button { + query = "" + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .accessibilityLabel("Clear search") + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color(nsColor: .textBackgroundColor)) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder( + isSearchFocused ? Color.accentColor.opacity(0.55) : Color.primary.opacity(0.10), + lineWidth: 1 + ) + ) + .shadow(color: .black.opacity(0.07), radius: 5, y: 2) + .accessibilityLabel("Search settings") + } + + private var searchResults: [SettingsItem] { + Array(SettingsItem.results(for: trimmedQuery).prefix(Self.maximumSearchResults)) + } + + private var searchResultsCard: some View { + let results = searchResults + return VStack(spacing: 0) { + if results.isEmpty { + VStack(spacing: 6) { + Image(systemName: "magnifyingglass") + .font(.system(size: 22)) + .foregroundStyle(.tertiary) + Text("No settings match \u{201C}\(trimmedQuery)\u{201D}") + .foregroundStyle(.secondary) + Text("Try \u{201C}color\u{201D}, \u{201C}shortcut\u{201D}, \u{201C}battery\u{201D}, or \u{201C}privacy\u{201D}.") + .font(.caption) + .foregroundStyle(.tertiary) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 28) + } else { + ForEach(Array(results.enumerated()), id: \.element.id) { index, item in + HomeSearchResultButton(item: item) { + open(item) + } + if index < results.count - 1 { + Divider().padding(.leading, 50) + } + } + } + } + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(.regularMaterial) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(Color.primary.opacity(0.07), lineWidth: 1) + ) + } + + private func openTopResult() { + guard let top = searchResults.first else { return } + open(top) + } + + private func open(_ item: SettingsItem) { + navigation.reveal(item) + query = "" + } + + // MARK: - Status row + + private var statusRow: some View { + HStack(spacing: 12) { + powerCard + engineCard + permissionsCard + } + } + + private var powerCard: some View { + HomeStatusCard( + systemImage: "power", + tint: suggestionSettings.isGloballyEnabled ? .green : .gray, + title: "Cotabby", + caption: suggestionSettings.isGloballyEnabled ? "Active" : "Paused" + ) { + Toggle("", isOn: globallyEnabledBinding) + .toggleStyle(.switch) + .controlSize(.small) + .labelsHidden() + .tint(.green) + .accessibilityLabel("Enable Cotabby globally") + } + } + + private var engineCard: some View { + Button { + navigation.open(.engineAndModel) + } label: { + HomeStatusCard( + systemImage: engineSystemImage, + tint: engineNeedsAttention ? .orange : SettingsCategory.engineAndModel.tint, + title: suggestionSettings.selectedEngine.displayLabel, + caption: engineCaption, + captionStyle: engineNeedsAttention ? .warning : .normal + ) { + HomeStatusChevron() + } + } + .buttonStyle(.plain) + .accessibilityLabel("Engine: \(suggestionSettings.selectedEngine.displayLabel), \(engineCaption)") + .accessibilityHint("Opens Engine & Model settings") + } + + private var permissionsCard: some View { + Button { + navigation.open(.permissions) + } label: { + HomeStatusCard( + systemImage: permissionManager.requiredPermissionsGranted ? "checkmark.shield.fill" : "exclamationmark.shield.fill", + tint: permissionManager.requiredPermissionsGranted ? SettingsCategory.permissions.tint : .orange, + title: "Permissions", + caption: permissionManager.requiredPermissionsGranted ? "All set" : "Needs attention", + captionStyle: permissionManager.requiredPermissionsGranted ? .normal : .warning + ) { + HomeStatusChevron() + } } - .padding(.vertical, 4) + .buttonStyle(.plain) + .accessibilityLabel( + "Permissions: \(permissionManager.requiredPermissionsGranted ? "all set" : "needs attention")" + ) + .accessibilityHint("Opens Permissions settings") } - @ViewBuilder - private var supportRow: some View { - VStack(alignment: .leading, spacing: 12) { - Text( - "Cotabby is free and open source, maintained by two students in our spare time. " - + "If it's useful to you, supporting development helps us keep improving it." + private var engineNeedsAttention: Bool { + attentionCategories.contains(.engineAndModel) + } + + private var engineSystemImage: String { + switch suggestionSettings.selectedEngine { + case .appleIntelligence: return "apple.logo" + case .llamaOpenSource: return "cpu.fill" + } + } + + private var engineCaption: String { + switch suggestionSettings.selectedEngine { + case .appleIntelligence: + return foundationModelAvailabilityService.isAvailable ? "Ready on this Mac" : "Unavailable" + case .llamaOpenSource: + let selected = runtimeModel.availableModels + .first { $0.filename == runtimeModel.selectedModelFilename } + return selected?.displayName ?? "No model selected" + } + } + + private var globallyEnabledBinding: Binding { + Binding( + get: { suggestionSettings.isGloballyEnabled }, + set: { suggestionSettings.setGloballyEnabled($0) } + ) + } + + // MARK: - Quick links + + private var quickLinksSection: some View { + VStack(alignment: .leading, spacing: 10) { + sectionHeader( + "Quick settings", + caption: "Jump straight to the controls you reach for most." + ) + + // Two columns at the default window width: three fit, but the captions truncate, + // and a quick link whose caption is cut loses the point of having one. + LazyVGrid( + columns: [GridItem(.adaptive(minimum: 260), spacing: 12)], + spacing: 12 + ) { + ForEach(Self.quickLinkCategories) { category in + SettingsQuickLinkCard(category: category) { + navigation.open(category) + } + } + } + } + } + + // MARK: - Showcase + + private var showcaseSection: some View { + VStack(alignment: .leading, spacing: 10) { + sectionHeader( + "See it in action", + caption: "Hover a card to watch it play." ) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) + OnboardingFeatureShowcase(autoplay: false, showsMacroReference: true) + } + } + // MARK: - Footer + + private var footer: some View { + HStack(spacing: 6) { + Text("Free & open source") + footerDot + if let repoURL = URL(string: "https://github.com/FuJacob/Cotabby") { + Link("GitHub", destination: repoURL) + } + footerDot if let supportURL = URL(string: "https://ko-fi.com/cotabby") { Link(destination: supportURL) { - Label("Support Cotabby", systemImage: "heart.fill") + Label("Support", systemImage: "heart.fill") + .labelStyle(.titleAndIcon) + .foregroundStyle(.pink) } - .buttonStyle(.borderedProminent) - .tint(.blue) } + footerDot + if let wikiURL = URL(string: "https://github.com/FuJacob/Cotabby/wiki") { + Link("Wiki", destination: wikiURL) + } + } + .font(.caption) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .padding(.top, 4) + } + + private var footerDot: some View { + Text("\u{00B7}") + .foregroundStyle(.tertiary) + .accessibilityHidden(true) + } + + // MARK: - Shared bits + + private func sectionHeader(_ title: String, caption: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.system(size: 15, weight: .semibold)) + Text(caption) + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +// MARK: - Status card chrome + +/// Shared chrome for one at-a-glance status card: tile, two text lines, and a trailing accessory +/// (a switch or a chevron). The card itself stays passive; interactive cards wrap it in a Button. +private struct HomeStatusCard: View { + enum CaptionStyle { + case normal + case warning + } + + let systemImage: String + let tint: Color + let title: String + let caption: String + var captionStyle: CaptionStyle = .normal + @ViewBuilder let accessory: () -> Accessory + + var body: some View { + HStack(spacing: 10) { + SettingsIconTile(systemImage: systemImage, tint: tint, size: 28) + + VStack(alignment: .leading, spacing: 1) { + Text(title) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + Text(caption) + .font(.caption) + .foregroundStyle(captionStyle == .warning ? AnyShapeStyle(.orange) : AnyShapeStyle(.secondary)) + .lineLimit(1) + .truncationMode(.middle) + } + + Spacer(minLength: 4) + + accessory() + } + .padding(.horizontal, 12) + .padding(.vertical, 12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(.regularMaterial) + ) + .overlay( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(Color.primary.opacity(0.07), lineWidth: 1) + ) + } +} + +private struct HomeStatusChevron: View { + var body: some View { + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.tertiary) + .accessibilityHidden(true) + } +} + +// MARK: - Search result row button + +/// One hero search hit with its own hover highlight. Split out so each row owns a single +/// `@State` instead of the page tracking hover indices. +private struct HomeSearchResultButton: View { + let item: SettingsItem + let action: () -> Void + + @State private var isHovering = false + + var body: some View { + Button(action: action) { + SettingsSearchResultRow(item: item, style: .full) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(isHovering ? Color.primary.opacity(0.06) : Color.clear) } + .buttonStyle(.plain) + .onHover { isHovering = $0 } } } diff --git a/Cotabby/UI/Settings/Panes/PerformancePaneView.swift b/Cotabby/UI/Settings/Panes/PerformancePaneView.swift index 13d99ba1..50bef65b 100644 --- a/Cotabby/UI/Settings/Panes/PerformancePaneView.swift +++ b/Cotabby/UI/Settings/Panes/PerformancePaneView.swift @@ -30,16 +30,21 @@ struct PerformancePaneView: View { systemImage: "stopwatch" ) } + .settingsItem(.performanceTracking) } Section { + // Both branches carry the anchor; only one exists at a time, so the scroll target + // stays unique whichever state the log is in. if performanceMetricsStore.entries.isEmpty { Text(emptyStateMessage) .font(.callout) .foregroundStyle(.secondary) .frame(maxWidth: .infinity, alignment: .leading) + .settingsItem(.recentRequests) } else { latencyChart + .settingsItem(.recentRequests) metricsTable } } header: { @@ -70,6 +75,7 @@ struct PerformancePaneView: View { private var suggestionQualitySection: some View { Section { qualityCounterRow(label: "Suggestions shown", value: "\(qualityMetricsStore.counters.shown)") + .settingsItem(.suggestionQualityStats) qualityCounterRow(label: "Accepted", value: acceptedLabel) qualityCounterRow(label: "Generations", value: "\(qualityMetricsStore.counters.generated)") if !topSuppressionReasons.isEmpty { @@ -137,6 +143,7 @@ struct PerformancePaneView: View { tint: .blue, valueLabel: cpuCurrentLabel ) + .settingsItem(.resourceUsage) MetricSparkline( points: ramPoints, yDomainUpper: ramDomainUpperMB, diff --git a/Cotabby/UI/Settings/Panes/PermissionsPaneView.swift b/Cotabby/UI/Settings/Panes/PermissionsPaneView.swift index 72ce0b55..49ccffde 100644 --- a/Cotabby/UI/Settings/Panes/PermissionsPaneView.swift +++ b/Cotabby/UI/Settings/Panes/PermissionsPaneView.swift @@ -25,6 +25,7 @@ struct PermissionsPaneView: View { granted: permissionManager.accessibilityGranted, permissionGuidanceController: permissionGuidanceController ) + .settingsItem(.accessibility) SettingsPermissionRow( permission: .inputMonitoring, @@ -34,6 +35,7 @@ struct PermissionsPaneView: View { granted: permissionManager.inputMonitoringGranted, permissionGuidanceController: permissionGuidanceController ) + .settingsItem(.inputMonitoring) SettingsPermissionRow( permission: .screenRecording, @@ -43,6 +45,7 @@ struct PermissionsPaneView: View { granted: permissionManager.screenRecordingGranted, permissionGuidanceController: permissionGuidanceController ) + .settingsItem(.screenRecording) } } .onAppear { permissionManager.refresh() } @@ -91,9 +94,13 @@ private struct SettingsPermissionRow: View { // neutral "Off" state or "Enable" verb. The pane-level warning callout still fires for // required permissions only, so nothing here claims autocomplete is broken when just // Screen Recording is missing. - Text(granted ? "Granted" : "Needs Access") - .font(.caption.weight(.medium)) - .foregroundStyle(granted ? .green : .orange) + HStack(spacing: 4) { + Image(systemName: granted ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") + .accessibilityHidden(true) + Text(granted ? "Granted" : "Needs Access") + } + .font(.caption.weight(.medium)) + .foregroundStyle(granted ? .green : .orange) if !granted { Button("Grant Access") { diff --git a/Cotabby/UI/Settings/Panes/ShortcutsPaneView.swift b/Cotabby/UI/Settings/Panes/ShortcutsPaneView.swift index e795907b..bc8b6d50 100644 --- a/Cotabby/UI/Settings/Panes/ShortcutsPaneView.swift +++ b/Cotabby/UI/Settings/Panes/ShortcutsPaneView.swift @@ -14,6 +14,7 @@ struct ShortcutsPaneView: View { SettingsPaneScaffold { Section("Mode") { AcceptanceModePickerView(suggestionSettings: suggestionSettings) + .settingsItem(.acceptanceMode) } Section("Keys") { @@ -55,6 +56,7 @@ struct ShortcutsPaneView: View { systemImage: "arrow.right.to.line" ) } + .settingsItem(.acceptWord) LabeledContent { KeybindRow( @@ -94,6 +96,7 @@ struct ShortcutsPaneView: View { systemImage: "text.insert" ) } + .settingsItem(.acceptEntireSuggestion) // No factory default — the hotkey is opt-in, so the only "reset" gesture that // makes sense is "unbind", which the Clear button already covers. Passing @@ -130,6 +133,7 @@ struct ShortcutsPaneView: View { systemImage: "power.circle" ) } + .settingsItem(.toggleTabby) } } } diff --git a/Cotabby/UI/Settings/Panes/WritingPaneView.swift b/Cotabby/UI/Settings/Panes/WritingPaneView.swift index b79527df..7767178b 100644 --- a/Cotabby/UI/Settings/Panes/WritingPaneView.swift +++ b/Cotabby/UI/Settings/Panes/WritingPaneView.swift @@ -23,6 +23,7 @@ struct WritingPaneView: View { systemImage: "ruler" ) } + .settingsItem(.length) // Min and Max are editable while Custom is active: type a value or nudge it with the // arrows. Both rows commit through `setCustomWordCountRange`, which clamps to @@ -56,6 +57,7 @@ struct WritingPaneView: View { systemImage: "textformat.abc" ) } + .settingsItem(.acceptPunctuation) Toggle(isOn: addSpaceAfterAcceptBinding) { SettingsRowLabel( @@ -65,6 +67,7 @@ struct WritingPaneView: View { systemImage: "space" ) } + .settingsItem(.addSpaceAfterAccept) } Section("Corrections") { @@ -75,6 +78,7 @@ struct WritingPaneView: View { systemImage: "eye.slash" ) } + .settingsItem(.hideSuggestionsOnTypo) Toggle(isOn: offerTypoCorrectionsBinding) { SettingsRowLabel( @@ -84,6 +88,7 @@ struct WritingPaneView: View { ) } .disabled(!suggestionSettings.suppressCompletionsOnTypo) + .settingsItem(.offerTypoCorrections) Toggle(isOn: automaticallyFixTyposBinding) { SettingsRowLabel( @@ -93,10 +98,12 @@ struct WritingPaneView: View { ) } .disabled(!suggestionSettings.suppressCompletionsOnTypo) + .settingsItem(.automaticallyFixTypos) Divider() SpellingDictionaryPicker(suggestionSettings: suggestionSettings) + .settingsItem(.spellingDictionaries) } Section("Profile") { @@ -123,6 +130,7 @@ struct WritingPaneView: View { } } .padding(.vertical, 6) + .settingsItem(.name) } // The editors suppress their own titles here so the Section headers ("Languages"/"Rules") @@ -130,6 +138,7 @@ struct WritingPaneView: View { Section("Languages") { LanguageTagsEditor(suggestionSettings: suggestionSettings, showsTitleHeader: false) .padding(.vertical, 6) + .settingsItem(.languages) } // Hidden while custom rules are gated off (CustomRulesCatalog.isUserFacingEnabled). The @@ -138,6 +147,7 @@ struct WritingPaneView: View { Section("Rules") { CustomRulesEditor(suggestionSettings: suggestionSettings, showsTitleHeader: false) .padding(.vertical, 6) + .settingsItem(.customRules) } } } diff --git a/Cotabby/UI/Settings/SettingsCategory.swift b/Cotabby/UI/Settings/SettingsCategory.swift index 9e554a86..a71af600 100644 --- a/Cotabby/UI/Settings/SettingsCategory.swift +++ b/Cotabby/UI/Settings/SettingsCategory.swift @@ -1,11 +1,12 @@ -import Foundation +import SwiftUI /// File overview: -/// The catalog of sidebar rows in the Settings window. The sidebar is intentionally a flat list -/// with no section headers: System Settings-style grouping was tried earlier but the extra header -/// chrome ate sidebar width and pushed labels into truncation. Engine-specific content (Apple -/// Intelligence vs. Open Source) lives inside the single Engine & Model pane and is switched via the -/// engine dropdown at the top of that pane. +/// The catalog of sidebar rows in the Settings window. The sidebar renders these as visually +/// clustered groups (see `sidebarGroups`) without header text: System Settings-style labeled +/// headers were tried earlier but the extra chrome ate sidebar width and pushed labels into +/// truncation, so the grouping is carried by spacing alone. Engine-specific content (Apple +/// Intelligence vs. Open Source) lives inside the single Engine & Model pane and is switched via +/// the engine dropdown at the top of that pane. /// /// Order reflects a top-down reading: core behavior, how suggestions look, the emoji feature, what /// the model is told (writing then context), the model itself, input bindings, then system and info. @@ -42,7 +43,7 @@ enum SettingsCategory: String, CaseIterable, Hashable, Identifiable { } } - /// SF Symbol displayed at the left of each sidebar row. + /// SF Symbol displayed in the sidebar tile and anywhere else the category is represented. var systemImage: String { switch self { case .home: return "house.fill" @@ -59,4 +60,54 @@ enum SettingsCategory: String, CaseIterable, Hashable, Identifiable { case .about: return "info.circle.fill" } } + + /// Tile tint behind the white symbol, mirroring System Settings' colored sidebar icons. The + /// same tint colors the category's search results and Home quick links so a pane keeps one + /// identity everywhere it appears. + var tint: Color { + switch self { + case .home: return .blue + case .general: return .gray + case .appearance: return .purple + case .emoji: return .yellow + case .writing: return .indigo + case .context: return .teal + case .engineAndModel: return .orange + case .shortcuts: return .pink + case .apps: return .red + case .permissions: return .cyan + case .performance: return .green + case .about: return .gray + } + } + + /// One-line caption used by the Home quick-link cards. + var summary: String { + switch self { + case .home: return "Overview and search" + case .general: return "Core toggles and behavior" + case .appearance: return "Ghost text style and display" + case .emoji: return "The inline emoji picker" + case .writing: return "Length, profile, and corrections" + case .context: return "What the model can reference" + case .engineAndModel: return "Choose the engine and models" + case .shortcuts: return "Keys that accept suggestions" + case .apps: return "Where Cotabby stays quiet" + case .permissions: return "System access and privacy" + case .performance: return "Latency, quality, and resources" + case .about: return "Version, support, and licenses" + } + } + + /// Sidebar clusters, in display order. Spacing between groups (not headers) carries the + /// structure: landing, everyday behavior and look, the intelligence itself, control and + /// access, then diagnostics and info. Every case must appear exactly once; a unit test + /// pins that invariant so a new pane cannot silently vanish from the sidebar. + static let sidebarGroups: [[SettingsCategory]] = [ + [.home], + [.general, .appearance, .emoji], + [.writing, .context, .engineAndModel], + [.shortcuts, .apps, .permissions], + [.performance, .about] + ] } diff --git a/Cotabby/UI/Settings/SettingsContainerView.swift b/Cotabby/UI/Settings/SettingsContainerView.swift index 0059d760..5cfbea6b 100644 --- a/Cotabby/UI/Settings/SettingsContainerView.swift +++ b/Cotabby/UI/Settings/SettingsContainerView.swift @@ -2,14 +2,16 @@ import AppKit import SwiftUI /// File overview: -/// Root of the redesigned Settings window. A `NavigationSplitView` with a sidebar of categorized -/// rows sits on the left and a switching detail pane fills the right side. The detail body is built -/// from `SettingsCategory`, so adding a new pane is a matter of adding an enum case and a `case` in -/// the switch below. +/// Root of the redesigned Settings window. A `NavigationSplitView` with a sidebar of clustered +/// category rows sits on the left and a switching detail pane fills the right side. The detail body +/// is built from `SettingsCategory`, so adding a new pane is a matter of adding an enum case and a +/// `case` in the switch below. /// -/// Selection is persisted via `@AppStorage` so reopening Settings lands on the last-used pane. -/// `.id(selection)` on the detail body is the documented workaround for the macOS 14 split-view -/// selection bug where the first sidebar pick doesn't always re-render the detail column. +/// Navigation flows through `SettingsNavigationModel` so the sidebar, the Home pane's search and +/// quick links, and the window-level Cmd-F shortcut all drive one source of truth. Selection is +/// persisted via `@AppStorage` so reopening Settings lands on the last-used pane. `.id(selection)` +/// on the detail body is the documented workaround for the macOS 14 split-view selection bug where +/// the first sidebar pick doesn't always re-render the detail column. struct SettingsContainerView: View { let appUpdateManager: AppUpdateManager @@ -30,7 +32,7 @@ struct SettingsContainerView: View { @AppStorage("cotabbySettingsSelectedCategoryV2") private var storedCategoryRawValue: String = SettingsCategory.home.rawValue - @State private var selection: SettingsCategory = .home + @StateObject private var navigation = SettingsNavigationModel() // Settings should behave like a traditional two-column preferences window: the sidebar is // always visible, but SwiftUI can still manage the native navigation/split-view chrome. @State private var columnVisibility: NavigationSplitViewVisibility = .all @@ -38,16 +40,32 @@ struct SettingsContainerView: View { var body: some View { NavigationSplitView(columnVisibility: $columnVisibility) { SettingsSidebarView( - selection: $selection, + navigation: navigation, attentionCategories: attentionCategories ) .toolbar(removing: .sidebarToggle) } detail: { detailPane - .id(selection) + .id(navigation.selection) .frame(maxWidth: .infinity, maxHeight: .infinity) .toolbar(removing: .sidebarToggle) + // SwiftUI owns the hosting window's title once a navigation stack is involved, so + // the title must be declared here rather than written to `NSWindow.title` (any + // AppKit-side write gets stomped on the next navigation update). Home is the + // landing surface rather than a pane of controls, so it titles the window with + // the app-wide name instead of the literal "Home". + .navigationTitle(navigation.selection == .home ? "Cotabby Settings" : navigation.selection.label) } + .environment(\.settingsHighlightedItem, navigation.highlightedItem) + // Window-level Cmd-F: jump to the search surface from any pane. The button renders + // nothing; it exists to host the keyboard shortcut inside this window's responder chain. + .background( + Button("") { navigation.requestSearchFocus() } + .keyboardShortcut("f", modifiers: .command) + .opacity(0) + .frame(width: 0, height: 0) + .accessibilityHidden(true) + ) // Keep the native split view, but pin the outer Settings window to a practical minimum. // The sidebar itself provides a width range, so the default opens readable without forcing // an exact column size forever. @@ -56,15 +74,11 @@ struct SettingsContainerView: View { // Migration: the previous sidebar had two engine sub-rows (`appleIntelligence`, // `openSource`). Users whose persisted selection still points to either should land on // the unified Engine & Model pane rather than fall back to General. - selection = Self.restoreSelection(from: storedCategoryRawValue) + navigation.selection = Self.restoreSelection(from: storedCategoryRawValue) permissionManager.refresh() - // Set the title unconditionally on open: when the restored selection equals the - // initial @State value, `.onChange` does not fire and the title would stay blank. - syncWindowTitle(for: selection) } - .onChange(of: selection) { _, newValue in + .onChange(of: navigation.selection) { _, newValue in storedCategoryRawValue = newValue.rawValue - syncWindowTitle(for: newValue) } .onChange(of: columnVisibility) { _, newValue in if newValue != .all { @@ -90,9 +104,16 @@ struct SettingsContainerView: View { @ViewBuilder private var detailPane: some View { - switch selection { + switch navigation.selection { case .home: - HomePaneView() + HomePaneView( + navigation: navigation, + suggestionSettings: suggestionSettings, + permissionManager: permissionManager, + foundationModelAvailabilityService: foundationModelAvailabilityService, + runtimeModel: runtimeModel, + attentionCategories: attentionCategories + ) case .general: GeneralPaneView( suggestionSettings: suggestionSettings, @@ -154,16 +175,16 @@ struct SettingsContainerView: View { return .general } - /// Mirrors the chosen pane into the hosting `NSWindow.title` so the title bar reflects the - /// current selection. macOS settings windows traditionally use an inline title for the active - /// pane; this preserves that convention without rendering a duplicate large title inside the - /// content. + /// Mirrors the chosen pane into the hosting `NSWindow.title`, matching System Settings where + /// the title bar names the active pane. Home is the landing surface rather than a pane of + /// controls, so it titles the window with the app-wide name instead of the literal "Home". private func syncWindowTitle(for category: SettingsCategory) { // Capture the key window now: between the tap and the async block running, a popover or // alert could become key and we would retitle the wrong window. let window = NSApp.keyWindow + let title = category == .home ? "Cotabby Settings" : category.label DispatchQueue.main.async { - window?.title = "Settings — \(category.label)" + window?.title = title } } } diff --git a/Cotabby/UI/Settings/SettingsIndex.swift b/Cotabby/UI/Settings/SettingsIndex.swift index 95c2bf25..3bc649c3 100644 --- a/Cotabby/UI/Settings/SettingsIndex.swift +++ b/Cotabby/UI/Settings/SettingsIndex.swift @@ -1,13 +1,16 @@ import Foundation /// File overview: -/// A searchable index of individual settings that powers the Settings search field. Each item knows -/// its display title, the pane (`SettingsCategory`) that hosts it, an SF Symbol, and extra keywords -/// so a query like "dark", "tab", or "startup" lands on the right pane. +/// A searchable index of individual settings that powers Settings search (the sidebar field and +/// the Home hero search). Each item knows its display title, the pane (`SettingsCategory`) that +/// hosts it, an SF Symbol, a one-line summary, and extra keywords so a query like "dark", "tab", +/// or "startup" lands on the right row. Relevance ordering comes from the pure +/// `SettingsSearchRanker`; this file only declares the catalog. /// /// This is a navigational map, not the rendering source: panes still own their own rows and labels. /// Keeping the index here means search coverage is reviewed in one place and a new setting is one -/// case away from being findable. +/// case away from being findable. Panes mark the matching row with `.settingsItem(_:)` so search +/// can scroll to and highlight it on arrival. enum SettingsItem: String, CaseIterable, Identifiable { // General case enableGlobally @@ -252,6 +255,75 @@ enum SettingsItem: String, CaseIterable, Identifiable { } } + /// One-line caption shown under the title in search results, and searched for descriptive + /// phrasing the title doesn't carry ("looks too big", "on every keystroke"). + var summary: String { + switch self { + case .enableGlobally: return "Turn Cotabby on or off everywhere without quitting." + case .fastMode: return "Skip screenshot context for faster suggestions." + case .openAtLogin: return "Start Cotabby automatically when you log in." + case .includeClipboardContext: return "Let suggestions reference what you last copied." + case .includeAppContext: return "Tell the model which app and window you are typing in." + case .allowMultiLine: return "Allow continuations that span more than one line." + case .acceptPunctuation: return "Also accept trailing commas and periods with a word." + case .addSpaceAfterAccept: return "Add a space when an accept finishes a word." + case .inlineMacros: return "Type / for dates, math, units, currency, and randoms." + case .onboarding: return "Replay the first-run setup walkthrough." + case .suggestionDisplay: return "Inline ghost text, popup card, or automatic per app." + 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 .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." + case .ghostTextSize: return "Scale suggestions if the ghost text looks too big or small." + case .emojiPicker: return "Type :name to search and insert emoji inline." + case .emojiSkinTone: return "Prefer a skin tone in emoji suggestions." + case .emojiPeopleStyle: return "Person, man, or woman variants when available." + case .emojiHistory: return "Clear recently and frequently used emoji." + case .length: return "How many words Cotabby aims for per suggestion." + case .name: return "What Cotabby should call you." + case .languages: return "Languages suggestions should be written in." + case .customRules: return "Your own style rules passed to the model." + case .hideSuggestionsOnTypo: return "Pause completions while a word looks misspelled." + case .offerTypoCorrections: return "Offer a green replacement for the misspelled word." + case .spellingDictionaries: return "Dictionaries used to detect typos." + case .automaticallyFixTypos: return "Replace a misspelled word right after you press Space." + case .extendedContext: return "A glossary or notes sent with every suggestion." + case .contextLivePreview: return "A real field that exercises the full pipeline." + case .engine: return "Apple Intelligence or an open-source local model." + case .appleIntelligenceAvailability: return "Whether this Mac can run Apple Intelligence." + case .modelStatus: return "Whether the local model is loaded and ready." + case .selectedModel: return "Which downloaded model generates suggestions." + case .powerBasedModelSwitching: return "Use a different engine or model by power source." + case .batteryModel: return "Engine and model used while on battery." + case .pluggedInModel: return "Engine and model used while plugged in." + case .downloadModels: return "Curated models you can download and run." + case .huggingFaceBrowser: return "Search Hugging Face for GGUF models." + case .modelsFolder: return "Where downloaded model files live on this Mac." + case .lmStudio: return "Also load models from your LM Studio library." + case .acceptanceMode: return "Whether the accept key takes a word or a phrase." + case .acceptWord: return "The key that inserts the next word." + case .acceptEntireSuggestion: return "The key that inserts the whole suggestion." + case .toggleTabby: return "A global hotkey that turns Cotabby on or off." + case .disabledApps: return "Apps where Cotabby never autocompletes." + case .suggestInIntegratedTerminals: return "Ghost text in VS Code and Cursor terminals." + case .accessibility: return "Required to read the focused field and caret." + case .inputMonitoring: return "Required to see keystrokes and the accept key." + case .screenRecording: return "Optional visual context from the focused window." + case .performanceTracking: return "Record timing for every model request." + case .suggestionQualityStats: return "Shown, accepted, and withheld counters." + case .resourceUsage: return "Live CPU and memory graphs for the app." + case .recentRequests: return "Latency log of the most recent generations." + case .checkForUpdates: return "See if a newer Cotabby is available." + case .support: return "Tip the two students who build Cotabby." + case .githubRepository: return "Browse the source code and issues." + case .wiki: return "Documentation and the contributor guide." + case .acknowledgements: return "Third-party packages Cotabby ships with." + case .uninstall: return "Remove Cotabby and its data from this Mac." + } + } + /// Extra terms a user might type that are not in the title, so search still finds the row. /// Lean toward generous synonyms (UI vocab, common typos, prior names, related features) so /// search behaves more like "find anything that mentions this" than strict label matching. @@ -449,17 +521,17 @@ enum SettingsItem: String, CaseIterable, Identifiable { } } - func matches(_ query: String) -> Bool { - let needle = query.lowercased() - if title.lowercased().contains(needle) { return true } - if category.label.lowercased().contains(needle) { return true } - return keywords.contains { $0.lowercased().contains(needle) } - } - - /// Items whose title or keywords match the query, in declaration order. Empty for a blank query. + /// Items matching the query, most relevant first. Empty for a blank query. Relevance and + /// typo tolerance come from `SettingsSearchRanker`, so this stays a thin catalog accessor. static func results(for query: String) -> [SettingsItem] { - let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return [] } - return allCases.filter { $0.matches(trimmed) } + SettingsSearchRanker.rank(query, in: allCases) } } + +/// Feeds the catalog's fields to the pure ranker without the ranker importing this UI type. +extension SettingsItem: SettingsSearchable { + var searchTitle: String { title } + var searchKeywords: [String] { keywords } + var searchGroupLabel: String { category.label } + var searchSummary: String { summary } +} diff --git a/Cotabby/UI/Settings/SettingsNavigationModel.swift b/Cotabby/UI/Settings/SettingsNavigationModel.swift new file mode 100644 index 00000000..f6068c35 --- /dev/null +++ b/Cotabby/UI/Settings/SettingsNavigationModel.swift @@ -0,0 +1,118 @@ +import Combine +import SwiftUI + +/// File overview: +/// Navigation state for the Settings window: which pane is selected, which individual setting +/// (if any) search wants revealed, and a pending request to focus the Home search field. Owned by +/// `SettingsContainerView` and shared with the sidebar and Home so a search hit anywhere can land +/// on the exact row: select the pane, scroll to the row, and pulse it briefly. +/// +/// The highlight is deliberately transient. It exists to answer "where did search drop me?", +/// not to become persistent selection state, so `reveal` schedules its own clear and any manual +/// pane switch cancels it immediately. +@MainActor +final class SettingsNavigationModel: ObservableObject { + @Published var selection: SettingsCategory = .home + /// The setting search navigated to, while its arrival pulse is active. Distributed to rows + /// through the `settingsHighlightedItem` environment value. + @Published private(set) var highlightedItem: SettingsItem? + /// True while Home owes the hero search field focus (set by the window-level Cmd-F shortcut). + /// Home consumes and clears it, so a later visit to Home does not steal focus again. + @Published private(set) var pendingSearchFocus = false + + private var highlightClearTask: Task? + + /// How long the arrival pulse stays before fading. Long enough to catch the eye after the + /// pane switch and scroll settle, short enough to never read as a stuck selection. + private static let highlightDuration: Duration = .seconds(2.4) + + /// Plain pane navigation (sidebar click, Home quick link). Cancels any in-flight highlight so + /// a stale pulse cannot replay when the user later returns to that pane. + func open(_ category: SettingsCategory) { + cancelHighlight() + selection = category + } + + /// Search navigation: selects the item's pane and pulses the row. The pane's scaffold watches + /// `highlightedItem` to perform the scroll. + func reveal(_ item: SettingsItem) { + selection = item.category + highlightedItem = item + + highlightClearTask?.cancel() + highlightClearTask = Task { [weak self] in + try? await Task.sleep(for: Self.highlightDuration) + guard !Task.isCancelled else { return } + self?.highlightedItem = nil + } + } + + /// Cmd-F: bring the user to the search surface. Switching to Home first means the shortcut + /// works from any pane. + func requestSearchFocus() { + if selection != .home { + open(.home) + } + pendingSearchFocus = true + } + + func consumeSearchFocusRequest() { + pendingSearchFocus = false + } + + private func cancelHighlight() { + highlightClearTask?.cancel() + highlightClearTask = nil + highlightedItem = nil + } +} + +// MARK: - Highlight environment + +/// The item whose arrival pulse is active, distributed as a plain environment value (rather than +/// the model object) so rows re-render only when the value actually changes. +private struct SettingsHighlightedItemKey: EnvironmentKey { + static let defaultValue: SettingsItem? = nil +} + +extension EnvironmentValues { + var settingsHighlightedItem: SettingsItem? { + get { self[SettingsHighlightedItemKey.self] } + set { self[SettingsHighlightedItemKey.self] = newValue } + } +} + +// MARK: - Row anchor + +extension View { + /// Marks a settings row as the home of `item` so search can scroll to it (`.id`) and pulse it + /// on arrival. Apply to the outermost row view inside a Form section (the `Toggle`, `Picker`, + /// or `LabeledContent` itself). + func settingsItem(_ item: SettingsItem) -> some View { + modifier(SettingsItemAnchorModifier(item: item)) + } +} + +private struct SettingsItemAnchorModifier: ViewModifier { + let item: SettingsItem + @Environment(\.settingsHighlightedItem) private var highlightedItem + + func body(content: Content) -> some View { + let isHighlighted = highlightedItem == item + content + .id(item) + // The wash draws behind the row content rather than via `listRowBackground`, which + // macOS grouped forms ignore. An always-present background whose opacity animates to + // zero keeps the idle row untouched (opacity 0 renders nothing) while letting the + // pulse fade smoothly instead of vanishing when the highlight clears. The negative + // padding spreads the wash a little past the content so it reads as a row highlight, + // not a text box; backgrounds never affect layout, so rows cannot shift. + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.accentColor.opacity(isHighlighted ? 0.16 : 0)) + .padding(.horizontal, -8) + .padding(.vertical, -5) + .animation(.easeInOut(duration: 0.6), value: isHighlighted) + ) + } +} diff --git a/Cotabby/UI/Settings/SettingsSidebarView.swift b/Cotabby/UI/Settings/SettingsSidebarView.swift index a9e5225b..cc00bdbd 100644 --- a/Cotabby/UI/Settings/SettingsSidebarView.swift +++ b/Cotabby/UI/Settings/SettingsSidebarView.swift @@ -2,16 +2,22 @@ import SwiftUI /// File overview: /// Sidebar of the Settings window. A search field sits at the top with breathing room above it, -/// then the content: with no query the flat list of category rows (each with an optional attention -/// dot); with a query the individual settings that match, grouped by their owning pane. Selecting a -/// result navigates to that pane and clears the search. +/// then the content: with no query, the category rows in visually clustered groups (each row a +/// tinted icon tile plus label, with an optional attention dot); with a query, the individual +/// settings that match, ranked by relevance. Selecting a result reveals that exact setting in its +/// pane (scroll plus pulse) and clears the search. /// /// Why a hand-rolled search field instead of `.searchable`: /// `.searchable(placement: .sidebar)` pins its field flush to the top of the column with no way to /// add padding above it, so it collides with the title bar. A plain field gives full control over -/// the top inset while keeping the same look. The search index itself lives in `SettingsItem`. +/// the top inset while keeping the same look. Ranking lives in `SettingsSearchRanker`; the catalog +/// in `SettingsItem`. +/// +/// Why groups carry no header text: labeled headers were tried and their indentation chrome ate +/// sidebar width until labels like "Engine & Model" truncated. Spacing alone communicates the +/// clusters without costing a point of row width. struct SettingsSidebarView: View { - @Binding var selection: SettingsCategory + @ObservedObject var navigation: SettingsNavigationModel let attentionCategories: Set @State private var searchText = "" @@ -27,11 +33,12 @@ struct SettingsSidebarView: View { searchResultsList } } - // `.navigationSplitViewColumnWidth` is only a hint — AppKit's underlying split view ignores - // it when the window is at or near its minimum, which truncated labels like "Engine &..." and - // "Permissio..." in earlier small-window screenshots. A direct `.frame()` is a real SwiftUI - // layout constraint, so the split view has to give the sidebar at least the minWidth. Keep - // the column-width hint as a paired ideal so a fresh window opens at the right size. + // `.navigationSplitViewColumnWidth` is only a hint; AppKit's underlying split view ignores + // it when the window is at or near its minimum, which truncated labels like "Engine &..." + // and "Permissio..." in earlier small-window screenshots. A direct `.frame()` is a real + // SwiftUI layout constraint, so the split view has to give the sidebar at least the + // minWidth. Keep the column-width hint as a paired ideal so a fresh window opens at the + // right size. .frame(minWidth: 300, idealWidth: 340) .navigationSplitViewColumnWidth(min: 300, ideal: 340, max: 420) } @@ -50,6 +57,7 @@ struct SettingsSidebarView: View { TextField("Search settings", text: $searchText) .textFieldStyle(.plain) + .onSubmit(openTopResult) if !trimmedQuery.isEmpty { Button { @@ -99,51 +107,61 @@ struct SettingsSidebarView: View { return "v\(shortVersion)" } + private var selectionBinding: Binding { + Binding( + get: { navigation.selection }, + set: { navigation.open($0) } + ) + } + private var categoryList: some View { - List(selection: $selection) { - ForEach(SettingsCategory.allCases) { row(for: $0) } + List(selection: selectionBinding) { + ForEach(Array(SettingsCategory.sidebarGroups.enumerated()), id: \.offset) { _, group in + Section { + ForEach(group) { row(for: $0) } + } + } } .listStyle(.sidebar) } private var searchResultsList: some View { - let groups = groupedResults(SettingsItem.results(for: trimmedQuery)) + let results = SettingsItem.results(for: trimmedQuery) return List { - if groups.isEmpty { + if results.isEmpty { Text("No settings match \u{201C}\(trimmedQuery)\u{201D}") .foregroundStyle(.secondary) } else { - ForEach(groups) { group in - Section(group.category.label) { - ForEach(group.items) { item in - Button { - selection = item.category - searchText = "" - } label: { - Label(item.title, systemImage: item.systemImage) - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } + ForEach(results) { item in + Button { + open(item) + } label: { + SettingsSearchResultRow(item: item, style: .compact) } + .buttonStyle(.plain) } } } .listStyle(.sidebar) } - private func groupedResults(_ items: [SettingsItem]) -> [SettingsSearchGroup] { - SettingsCategory.allCases.compactMap { category in - let matching = items.filter { $0.category == category } - return matching.isEmpty ? nil : SettingsSearchGroup(category: category, items: matching) - } + /// Return inside the field commits the best match so search works without reaching for the + /// pointer. + private func openTopResult() { + guard let top = SettingsItem.results(for: trimmedQuery).first else { return } + open(top) + } + + private func open(_ item: SettingsItem) { + navigation.reveal(item) + searchText = "" } @ViewBuilder private func row(for category: SettingsCategory) -> some View { - HStack(spacing: 6) { - Label(category.label, systemImage: category.systemImage) + HStack(spacing: 8) { + SettingsIconTile(systemImage: category.systemImage, tint: category.tint, size: 20) + Text(category.label) Spacer(minLength: 0) if attentionCategories.contains(category) { Circle() @@ -155,12 +173,3 @@ struct SettingsSidebarView: View { .tag(category) } } - -/// One pane's worth of search results. Identified by its category so the grouped result list has a -/// stable identity for `ForEach`. -private struct SettingsSearchGroup: Identifiable { - let category: SettingsCategory - let items: [SettingsItem] - - var id: SettingsCategory { category } -} diff --git a/CotabbyTests/SettingsIndexTests.swift b/CotabbyTests/SettingsIndexTests.swift index af7fc8f7..a42b1d5a 100644 --- a/CotabbyTests/SettingsIndexTests.swift +++ b/CotabbyTests/SettingsIndexTests.swift @@ -6,14 +6,24 @@ import XCTest /// cheap invariants loud: every item must carry a non-empty title, symbol, and keyword set, and /// the queries users actually type for recently shipped settings must land on them. final class SettingsIndexTests: XCTestCase { - func test_everyItemHasTitleSymbolAndKeywords() { + func test_everyItemHasTitleSymbolKeywordsAndSummary() { for item in SettingsItem.allCases { XCTAssertFalse(item.title.isEmpty, "\(item) needs a title") XCTAssertFalse(item.systemImage.isEmpty, "\(item) needs an SF Symbol") XCTAssertFalse(item.keywords.isEmpty, "\(item) needs search keywords") + XCTAssertFalse(item.summary.isEmpty, "\(item) needs a one-line summary for search results") } } + func test_sidebarGroupsCoverEveryCategoryExactlyOnce() { + // The sidebar renders from `sidebarGroups`, not `allCases`, so a category missing from the + // groups would silently disappear from the window. Order is pinned too: the flattened + // groups must read in the same top-down sequence the enum declares. + let flattened = SettingsCategory.sidebarGroups.flatMap { $0 } + XCTAssertEqual(flattened, SettingsCategory.allCases, + "sidebar groups must list every category exactly once, in declaration order") + } + func test_itemIdsAreUnique() { let ids = SettingsItem.allCases.map(\.id) XCTAssertEqual(ids.count, Set(ids).count, "duplicate SettingsItem ids break Identifiable lists") diff --git a/CotabbyTests/SettingsSearchRankerTests.swift b/CotabbyTests/SettingsSearchRankerTests.swift new file mode 100644 index 00000000..70bbedee --- /dev/null +++ b/CotabbyTests/SettingsSearchRankerTests.swift @@ -0,0 +1,88 @@ +import XCTest +@testable import Cotabby + +/// Pins the relevance behavior of Settings search. The ranker is the difference between "search +/// finds something" and "search finds the right thing first", so these tests encode the ordering +/// promises the UI relies on: direct title hits beat synonym hits, multi-word queries converge on +/// the one row that matches every word, and near-miss typos still land. +final class SettingsSearchRankerTests: XCTestCase { + func test_exactTitleOutranksKeywordMatch() { + let results = SettingsSearchRanker.rank("languages", in: SettingsItem.allCases) + XCTAssertEqual(results.first, .languages, + "a query that IS a row's title should put that row first") + XCTAssertTrue(results.contains(.spellingDictionaries), + "keyword matches should still appear below the direct hit") + } + + func test_titlePrefixOutranksKeywordOnlyMatches() { + let results = SettingsSearchRanker.rank("ghost", in: SettingsItem.allCases) + let topThree = Array(results.prefix(3)) + XCTAssertEqual( + Set(topThree), + Set([.ghostTextColor, .ghostTextOpacity, .ghostTextSize]), + "rows titled Ghost Text … should outrank rows that only mention ghost in keywords" + ) + } + + func test_multiWordQueryConvergesOnTheRowMatchingEveryWord() { + XCTAssertEqual( + SettingsSearchRanker.rank("ghost size", in: SettingsItem.allCases).first, + .ghostTextSize + ) + XCTAssertEqual( + SettingsSearchRanker.rank("emoji history", in: SettingsItem.allCases).first, + .emojiHistory + ) + } + + func test_multiWordQueryRequiresEveryWordToMatch() { + let results = SettingsSearchRanker.rank("ghost spaceship", in: SettingsItem.allCases) + XCTAssertTrue(results.isEmpty, + "a token that matches nothing should fail the whole query, not be ignored") + } + + func test_subsequenceMatchingCatchesNearMissTypos() { + XCTAssertTrue( + SettingsSearchRanker.rank("batery", in: SettingsItem.allCases).contains(.batteryModel), + "a dropped letter should still find the row via subsequence matching" + ) + } + + func test_paneLabelQuerySurfacesThePanesItems() { + let results = SettingsSearchRanker.rank("emoji", in: SettingsItem.allCases) + for item in [SettingsItem.emojiPicker, .emojiSkinTone, .emojiPeopleStyle, .emojiHistory] { + XCTAssertTrue(results.contains(item), "pane-name query should include \(item)") + } + } + + func test_summaryTextIsSearchable() { + XCTAssertTrue( + SettingsSearchRanker.rank("misspelled", in: SettingsItem.allCases) + .contains(.hideSuggestionsOnTypo), + "summary phrasing should be matchable even when title and keywords miss" + ) + } + + func test_blankAndWhitespaceQueriesReturnNothing() { + XCTAssertTrue(SettingsSearchRanker.rank("", in: SettingsItem.allCases).isEmpty) + XCTAssertTrue(SettingsSearchRanker.rank(" ", in: SettingsItem.allCases).isEmpty) + } + + func test_everyItemIsTheTopResultForItsOwnTitle() { + // The strongest find-anything guarantee: typing a row's exact title always puts that row + // first. If a new item's title collides with existing keywords hard enough to lose, this + // fails and the title or weights need attention. + for item in SettingsItem.allCases { + let results = SettingsSearchRanker.rank(item.title, in: SettingsItem.allCases) + XCTAssertEqual(results.first, item, + "\"\(item.title)\" should rank \(item) first, got \(String(describing: results.first))") + } + } + + func test_diacriticsFoldIntoPlainLetters() { + XCTAssertTrue( + SettingsSearchRanker.rank("émoji", in: SettingsItem.allCases).contains(.emojiPicker), + "accented input should match unaccented catalog text" + ) + } +} From 38925b9d554184d4f6d44f26adaa48594f63853b Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Fri, 12 Jun 2026 01:26:23 -0700 Subject: [PATCH 2/2] refactor(settings): address review on the settings revamp - Remove the dead syncWindowTitle helper (navigationTitle owns the window title now) and the AppKit import it required. - Repair-pass the reveal scroll: a second staggered scrollTo covers slow layout instead of trusting one 80 ms guess. - Sidebar Return key opens from the same computed results the list renders. - Share the display-version formatting between the sidebar header and the Home hero via Bundle.cotabbyDisplayVersion. --- Cotabby.xcodeproj/project.pbxproj | 6 ++++++ Cotabby/Support/BundleVersion.swift | 17 +++++++++++++++++ .../Components/SettingsPaneScaffold.swift | 11 +++++++++-- Cotabby/UI/Settings/Panes/HomePaneView.swift | 6 +----- Cotabby/UI/Settings/SettingsContainerView.swift | 14 -------------- Cotabby/UI/Settings/SettingsSidebarView.swift | 16 +++++++++------- 6 files changed, 42 insertions(+), 28 deletions(-) create mode 100644 Cotabby/Support/BundleVersion.swift diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index 1262ea36..22a02434 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -21,6 +21,7 @@ 057CEA7858012C1501F1785C /* MarkerSelectionSynthesizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A863F41C0C03D7B4AC5DC002 /* MarkerSelectionSynthesizer.swift */; }; 05CC25E51682528CE2E73446 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = BC4F887528AE74AC0DD30314 /* Assets.xcassets */; }; 0609E4F29F537530A49C6A50 /* FocusSessionScopedCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E504DABB83411B3FA0B8DC5 /* FocusSessionScopedCache.swift */; }; + 06A310C087B460289B5ACCFE /* BundleVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01D84EED1A6A711F39DEA18F /* BundleVersion.swift */; }; 06B7E7339877B334B28BE2D3 /* TypoGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8412FE2BAC406421248A03B /* TypoGate.swift */; }; 06CFA03207FF92EB272A66F2 /* CaretLinePosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77364C1AF183EF1C0A4074D /* CaretLinePosition.swift */; }; 0777169DC861BD87C4C1D729 /* TextLayoutCaretEstimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BF8DC1860CCF0DFA3A3DFD7 /* TextLayoutCaretEstimator.swift */; }; @@ -570,6 +571,7 @@ D3B43622E5A41B11E7AF527E /* TrailingDuplicationFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D408D647412C59F3E692C42B /* TrailingDuplicationFilter.swift */; }; D3BC4EA192B234EB22361186 /* SettingsSearchRanker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 866D74711A35E0085D2A4BB3 /* SettingsSearchRanker.swift */; }; D46A0DB70B07F487431F48F6 /* EmojiPickerPanelLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7B185BA246A526CBA85E581 /* EmojiPickerPanelLayoutTests.swift */; }; + D46BCACE71169BF8403948CE /* BundleVersion.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01D84EED1A6A711F39DEA18F /* BundleVersion.swift */; }; D4DD6B6D22598BB3B98792DA /* AGPL-3.0.txt in Resources */ = {isa = PBXBuildFile; fileRef = 6F0EE728C0B1A7AD6B19CD0C /* AGPL-3.0.txt */; }; D553BAA6C9F478533BD4A221 /* PermissionsPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7113D3373525113CA69E7597 /* PermissionsPaneView.swift */; }; D5CAF3B590E5EC2AFC72E57A /* VisualContextStartCoalescerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 050D929E13BE52E6282B64D2 /* VisualContextStartCoalescerTests.swift */; }; @@ -685,6 +687,7 @@ 00BB95A341A8B5F4A1725640 /* SuggestionSettingsModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSettingsModelTests.swift; sourceTree = ""; }; 00D226C0B54B3B375EC2682D /* FoundationModelAvailabilityServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationModelAvailabilityServiceTests.swift; sourceTree = ""; }; 01B72736E416910878E8E493 /* OnboardingTemplateRecommenderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTemplateRecommenderTests.swift; sourceTree = ""; }; + 01D84EED1A6A711F39DEA18F /* BundleVersion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundleVersion.swift; sourceTree = ""; }; 01F583E92B0A78212B330E6E /* InputSuppressionControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputSuppressionControllerTests.swift; sourceTree = ""; }; 023144451BB30F981D1F9EE6 /* EmojiPopularityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPopularityTests.swift; sourceTree = ""; }; 033A468451259A3214EECBE5 /* InlinePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlinePreviewView.swift; sourceTree = ""; }; @@ -1663,6 +1666,7 @@ B997EC69E1C65B1E18234221 /* BrowserAppDetector.swift */, AD8025E4A296845FC53E660D /* BrowserDomain.swift */, AA33F5FFAC5B99384E15CE3E /* BundledRuntimeLocator.swift */, + 01D84EED1A6A711F39DEA18F /* BundleVersion.swift */, E3C84377F352140759B448C9 /* CaretGeometrySelector.swift */, D77364C1AF183EF1C0A4074D /* CaretLinePosition.swift */, 96495E4147D828C0B1B22765 /* ClipboardContentDistiller.swift */, @@ -1967,6 +1971,7 @@ 74422BB837D6A319D12BF981 /* BaseCompletionPromptRenderer.swift in Sources */, 79AA66B111C059B342338443 /* BrowserAppDetector.swift in Sources */, 2BE029A192E82E795490DC7F /* BrowserDomain.swift in Sources */, + 06A310C087B460289B5ACCFE /* BundleVersion.swift in Sources */, 07E50A9ECCE55072DA311F8F /* BundledRuntimeLocator.swift in Sources */, 7C72A2D76E8BA38ADD523CF6 /* CaretGeometrySelector.swift in Sources */, 06CFA03207FF92EB272A66F2 /* CaretLinePosition.swift in Sources */, @@ -2205,6 +2210,7 @@ E4382BEA8A8551612E5966B9 /* BaseCompletionPromptRenderer.swift in Sources */, 49C91DE326A590708D76102A /* BrowserAppDetector.swift in Sources */, 7D9C3D733CE7633FB12A35BE /* BrowserDomain.swift in Sources */, + D46BCACE71169BF8403948CE /* BundleVersion.swift in Sources */, 3CBBC3BFAC0DC8952EE24EF7 /* BundledRuntimeLocator.swift in Sources */, 76FD91607794883F8E121450 /* CaretGeometrySelector.swift in Sources */, 543D71217CA218426E9638BF /* CaretLinePosition.swift in Sources */, diff --git a/Cotabby/Support/BundleVersion.swift b/Cotabby/Support/BundleVersion.swift new file mode 100644 index 00000000..54d87e5e --- /dev/null +++ b/Cotabby/Support/BundleVersion.swift @@ -0,0 +1,17 @@ +import Foundation + +/// File overview: +/// Shared human-facing version text. The sidebar header and the Home hero both show the short +/// marketing version; formatting it in one place keeps the two surfaces from drifting apart. +/// (The About pane intentionally uses its own longer "Version X (build)" format.) +extension Bundle { + /// Short marketing version prefixed for display (e.g. "v1.0"), or nil when the bundle carries + /// no version string (some test hosts). + var cotabbyDisplayVersion: String? { + guard let shortVersion = object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String, + !shortVersion.isEmpty else { + return nil + } + return "v\(shortVersion)" + } +} diff --git a/Cotabby/UI/Settings/Components/SettingsPaneScaffold.swift b/Cotabby/UI/Settings/Components/SettingsPaneScaffold.swift index f3807bfe..11369275 100644 --- a/Cotabby/UI/Settings/Components/SettingsPaneScaffold.swift +++ b/Cotabby/UI/Settings/Components/SettingsPaneScaffold.swift @@ -51,14 +51,21 @@ struct SettingsPaneScaffold: View { } .onAppear { // The pane is rebuilt on every sidebar switch (`.id(selection)` in the container), - // so a search arrival lands here before rows have laid out. A one-beat delay lets - // the form settle so `scrollTo` has a real geometry target. + // so a search arrival lands here before rows have laid out. Two staggered attempts + // instead of one timed guess: the first lands once typical layout has settled, the + // second repairs the rare slow-machine case where layout finished late. `scrollTo` + // to an already-centered anchor is a visual no-op, so the repair pass is invisible + // whenever the first attempt worked. guard let item = highlightedItem else { return } Task { @MainActor in try? await Task.sleep(for: .milliseconds(80)) withAnimation(.easeInOut(duration: 0.35)) { proxy.scrollTo(item, anchor: .center) } + try? await Task.sleep(for: .milliseconds(350)) + withAnimation(.easeInOut(duration: 0.35)) { + proxy.scrollTo(item, anchor: .center) + } } } .onChange(of: highlightedItem) { _, item in diff --git a/Cotabby/UI/Settings/Panes/HomePaneView.swift b/Cotabby/UI/Settings/Panes/HomePaneView.swift index 4beb9361..37bea373 100644 --- a/Cotabby/UI/Settings/Panes/HomePaneView.swift +++ b/Cotabby/UI/Settings/Panes/HomePaneView.swift @@ -136,11 +136,7 @@ struct HomePaneView: View { } private var appVersionText: String? { - guard let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String, - !shortVersion.isEmpty else { - return nil - } - return "v\(shortVersion)" + Bundle.main.cotabbyDisplayVersion } // MARK: - Search diff --git a/Cotabby/UI/Settings/SettingsContainerView.swift b/Cotabby/UI/Settings/SettingsContainerView.swift index 5cfbea6b..7fffe6eb 100644 --- a/Cotabby/UI/Settings/SettingsContainerView.swift +++ b/Cotabby/UI/Settings/SettingsContainerView.swift @@ -1,4 +1,3 @@ -import AppKit import SwiftUI /// File overview: @@ -174,17 +173,4 @@ struct SettingsContainerView: View { } return .general } - - /// Mirrors the chosen pane into the hosting `NSWindow.title`, matching System Settings where - /// the title bar names the active pane. Home is the landing surface rather than a pane of - /// controls, so it titles the window with the app-wide name instead of the literal "Home". - private func syncWindowTitle(for category: SettingsCategory) { - // Capture the key window now: between the tap and the async block running, a popover or - // alert could become key and we would retitle the wrong window. - let window = NSApp.keyWindow - let title = category == .home ? "Cotabby Settings" : category.label - DispatchQueue.main.async { - window?.title = title - } - } } diff --git a/Cotabby/UI/Settings/SettingsSidebarView.swift b/Cotabby/UI/Settings/SettingsSidebarView.swift index cc00bdbd..f22c382f 100644 --- a/Cotabby/UI/Settings/SettingsSidebarView.swift +++ b/Cotabby/UI/Settings/SettingsSidebarView.swift @@ -100,11 +100,7 @@ struct SettingsSidebarView: View { /// Short marketing version (e.g. "v1.0"), or nil if the bundle has no version string. private var appVersionText: String? { - guard let shortVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String, - !shortVersion.isEmpty else { - return nil - } - return "v\(shortVersion)" + Bundle.main.cotabbyDisplayVersion } private var selectionBinding: Binding { @@ -125,8 +121,14 @@ struct SettingsSidebarView: View { .listStyle(.sidebar) } + /// Single source for the rendered results AND the Return-key action, so the key always opens + /// exactly what the list shows. + private var searchResults: [SettingsItem] { + SettingsItem.results(for: trimmedQuery) + } + private var searchResultsList: some View { - let results = SettingsItem.results(for: trimmedQuery) + let results = searchResults return List { if results.isEmpty { Text("No settings match \u{201C}\(trimmedQuery)\u{201D}") @@ -148,7 +150,7 @@ struct SettingsSidebarView: View { /// Return inside the field commits the best match so search works without reaching for the /// pointer. private func openTopResult() { - guard let top = SettingsItem.results(for: trimmedQuery).first else { return } + guard let top = searchResults.first else { return } open(top) }