diff --git a/CodeEdit.xcodeproj/project.pbxproj b/CodeEdit.xcodeproj/project.pbxproj index 3edecc7d2..1f36a6a21 100644 --- a/CodeEdit.xcodeproj/project.pbxproj +++ b/CodeEdit.xcodeproj/project.pbxproj @@ -286,6 +286,7 @@ 5B698A162B263BCE00DE9392 /* SearchSettingsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B698A152B263BCE00DE9392 /* SearchSettingsModel.swift */; }; 5C4BB1E128212B1E00A92FB2 /* World.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4BB1E028212B1E00A92FB2 /* World.swift */; }; 610C0FDA2B44438F00A01CA7 /* WorkspaceDocument+FindAndReplace.swift in Sources */ = {isa = PBXBuildFile; fileRef = 610C0FD92B44438F00A01CA7 /* WorkspaceDocument+FindAndReplace.swift */; }; + 611028C82C8DC7F200DFD845 /* MenuWithButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 611028C72C8DC7F100DFD845 /* MenuWithButtonStyle.swift */; }; 611191FA2B08CC9000D4459B /* SearchIndexer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 611191F92B08CC9000D4459B /* SearchIndexer.swift */; }; 611191FC2B08CCB800D4459B /* SearchIndexer+AsyncController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 611191FB2B08CCB800D4459B /* SearchIndexer+AsyncController.swift */; }; 611191FE2B08CCD200D4459B /* SearchIndexer+File.swift in Sources */ = {isa = PBXBuildFile; fileRef = 611191FD2B08CCD200D4459B /* SearchIndexer+File.swift */; }; @@ -319,6 +320,7 @@ 617DB3DA2C25B07F00B58BFE /* TaskNotificationsDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 617DB3D92C25B07F00B58BFE /* TaskNotificationsDetailView.swift */; }; 617DB3DC2C25B14A00B58BFE /* ActivityViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 617DB3DB2C25B14A00B58BFE /* ActivityViewer.swift */; }; 617DB3DF2C25E13800B58BFE /* TaskNotificationHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 617DB3DE2C25E13800B58BFE /* TaskNotificationHandlerTests.swift */; }; + 61816B832C81DC2C00C71BF7 /* SearchField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61816B822C81DC2C00C71BF7 /* SearchField.swift */; }; 618725A12C29EFCC00987354 /* SchemeDropDownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618725A02C29EFCC00987354 /* SchemeDropDownView.swift */; }; 618725A42C29F00400987354 /* WorkspaceMenuItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618725A32C29F00400987354 /* WorkspaceMenuItemView.swift */; }; 618725A62C29F02500987354 /* DropdownMenuItemStyleModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 618725A52C29F02500987354 /* DropdownMenuItemStyleModifier.swift */; }; @@ -338,6 +340,7 @@ 61A3E3E72C33383100076BD3 /* EnvironmentVariableListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61A3E3E62C33383100076BD3 /* EnvironmentVariableListItem.swift */; }; 61A53A7E2B4449870093BF8A /* WorkspaceDocument+Find.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61A53A7D2B4449870093BF8A /* WorkspaceDocument+Find.swift */; }; 61A53A812B4449F00093BF8A /* WorkspaceDocument+Index.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61A53A802B4449F00093BF8A /* WorkspaceDocument+Index.swift */; }; + 61C7E82F2C6CDBA500845336 /* Theme+FuzzySearchable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61C7E82E2C6CDBA500845336 /* Theme+FuzzySearchable.swift */; }; 61FB03AC2C3C1FDF001B3671 /* ShellTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FB03AB2C3C1FDF001B3671 /* ShellTests.swift */; }; 61FB03AE2C3C2493001B3671 /* CEActiveTaskTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FB03AD2C3C2493001B3671 /* CEActiveTaskTests.swift */; }; 61FB03B02C3C76AF001B3671 /* TaskManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61FB03AF2C3C76AF001B3671 /* TaskManagerTests.swift */; }; @@ -947,6 +950,7 @@ 5B698A152B263BCE00DE9392 /* SearchSettingsModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSettingsModel.swift; sourceTree = ""; }; 5C4BB1E028212B1E00A92FB2 /* World.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = World.swift; sourceTree = ""; }; 610C0FD92B44438F00A01CA7 /* WorkspaceDocument+FindAndReplace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceDocument+FindAndReplace.swift"; sourceTree = ""; }; + 611028C72C8DC7F100DFD845 /* MenuWithButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuWithButtonStyle.swift; sourceTree = ""; }; 611191F92B08CC9000D4459B /* SearchIndexer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchIndexer.swift; sourceTree = ""; }; 611191FB2B08CCB800D4459B /* SearchIndexer+AsyncController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchIndexer+AsyncController.swift"; sourceTree = ""; }; 611191FD2B08CCD200D4459B /* SearchIndexer+File.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SearchIndexer+File.swift"; sourceTree = ""; }; @@ -977,6 +981,7 @@ 617DB3D92C25B07F00B58BFE /* TaskNotificationsDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskNotificationsDetailView.swift; sourceTree = ""; }; 617DB3DB2C25B14A00B58BFE /* ActivityViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityViewer.swift; sourceTree = ""; }; 617DB3DE2C25E13800B58BFE /* TaskNotificationHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskNotificationHandlerTests.swift; sourceTree = ""; }; + 61816B822C81DC2C00C71BF7 /* SearchField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchField.swift; sourceTree = ""; }; 618725A02C29EFCC00987354 /* SchemeDropDownView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SchemeDropDownView.swift; sourceTree = ""; }; 618725A32C29F00400987354 /* WorkspaceMenuItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceMenuItemView.swift; sourceTree = ""; }; 618725A52C29F02500987354 /* DropdownMenuItemStyleModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropdownMenuItemStyleModifier.swift; sourceTree = ""; }; @@ -996,6 +1001,7 @@ 61A3E3E62C33383100076BD3 /* EnvironmentVariableListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentVariableListItem.swift; sourceTree = ""; }; 61A53A7D2B4449870093BF8A /* WorkspaceDocument+Find.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceDocument+Find.swift"; sourceTree = ""; }; 61A53A802B4449F00093BF8A /* WorkspaceDocument+Index.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkspaceDocument+Index.swift"; sourceTree = ""; }; + 61C7E82E2C6CDBA500845336 /* Theme+FuzzySearchable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Theme+FuzzySearchable.swift"; sourceTree = ""; }; 61D435CB2C29699800D032B8 /* TaskManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TaskManager.swift; sourceTree = ""; }; 61D435CD2C2969C300D032B8 /* CEActiveTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CEActiveTask.swift; sourceTree = ""; }; 61D435D12C2969D800D032B8 /* CETaskStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CETaskStatus.swift; sourceTree = ""; }; @@ -2027,6 +2033,8 @@ B67DB0FB2AFDF71F002DC647 /* IconToggleStyle.swift */, 587B9D8D29300ABD00AC7927 /* SearchPanel.swift */, 6CABB1A029C5593800340467 /* SearchPanelView.swift */, + 61816B822C81DC2C00C71BF7 /* SearchField.swift */, + 611028C72C8DC7F100DFD845 /* MenuWithButtonStyle.swift */, 587B9D8929300ABD00AC7927 /* PanelDivider.swift */, B67DB0EE2AF3E381002DC647 /* PaneTextField.swift */, 587B9D8E29300ABD00AC7927 /* PressActionsModifier.swift */, @@ -3508,6 +3516,7 @@ B6EA1FE429DA33DB001BF195 /* ThemeModel.swift */, B624232F2C21EE280096668B /* ThemeModel+CRUD.swift */, B6EA1FE629DA341D001BF195 /* Theme.swift */, + 61C7E82E2C6CDBA500845336 /* Theme+FuzzySearchable.swift */, ); path = Models; sourceTree = ""; @@ -4165,6 +4174,7 @@ 30AB4EBD2BF71CA800ED4431 /* DeveloperSettingsView.swift in Sources */, 6C6BD6EF29CD12E900235D17 /* ExtensionManagerWindow.swift in Sources */, 30B087FF2C0D53080063A882 /* LanguageServer+Completion.swift in Sources */, + 61C7E82F2C6CDBA500845336 /* Theme+FuzzySearchable.swift in Sources */, 6CFF967629BEBCD900182D6F /* FileCommands.swift in Sources */, B60718462B17DC15009CDAB4 /* RepoOutlineGroupItem.swift in Sources */, 613899B32B6E6FEE00A5CAF6 /* FuzzySearchable.swift in Sources */, @@ -4191,6 +4201,7 @@ 30AB4EBB2BF718A100ED4431 /* DeveloperSettings.swift in Sources */, B6C4F2A92B3CB00100B2B140 /* CommitDetailsHeaderView.swift in Sources */, 30B088012C0D53080063A882 /* LanguageServer+Definition.swift in Sources */, + 611028C82C8DC7F200DFD845 /* MenuWithButtonStyle.swift in Sources */, B6EA1FFB29DB78F6001BF195 /* ThemeSettingsThemeDetails.swift in Sources */, 618725A62C29F02500987354 /* DropdownMenuItemStyleModifier.swift in Sources */, 587B9E7029301D8F00AC7927 /* GitLabUser.swift in Sources */, @@ -4248,6 +4259,7 @@ 6C97EBCC2978760400302F95 /* AcknowledgementsWindowController.swift in Sources */, 284DC84F2978B7B400BF2770 /* ContributorsView.swift in Sources */, B62AEDC92A2704F3009A9F52 /* UtilityAreaTabView.swift in Sources */, + 61816B832C81DC2C00C71BF7 /* SearchField.swift in Sources */, 30B088052C0D53080063A882 /* LanguageServer+DocumentLink.swift in Sources */, 58798250292E78D80085B254 /* CodeFileDocument.swift in Sources */, 5878DAA5291AE76700DD95A3 /* OpenQuicklyView.swift in Sources */, diff --git a/CodeEdit/Features/CodeEditUI/Views/MenuWithButtonStyle.swift b/CodeEdit/Features/CodeEditUI/Views/MenuWithButtonStyle.swift new file mode 100644 index 000000000..2e432354f --- /dev/null +++ b/CodeEdit/Features/CodeEditUI/Views/MenuWithButtonStyle.swift @@ -0,0 +1,33 @@ +// +// MenuWithButtonStyle.swift +// CodeEdit +// +// Created by Tommy Ludwig on 08.09.24. +// + +import SwiftUI + +/// A menu styled to resemble a bordered button. +struct MenuWithButtonStyle: View { + var systemImage: String + var menu: () -> MenuView + var body: some View { + Menu { menu() } label: {} + .background { + Button {} label: { + HStack { + Image(systemName: systemImage) + Image(systemName: "chevron.down") + .resizable() + .fontWeight(.bold) + .frame(width: 8, height: 4.8) + .padding(.leading, -1.5) + .padding(.trailing, -2) + }.offset(y: 1) + } + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .frame(width: 30) + } +} diff --git a/CodeEdit/Features/CodeEditUI/Views/SearchField.swift b/CodeEdit/Features/CodeEditUI/Views/SearchField.swift new file mode 100644 index 000000000..ed6c8e2c7 --- /dev/null +++ b/CodeEdit/Features/CodeEditUI/Views/SearchField.swift @@ -0,0 +1,51 @@ +// +// SearchField.swift +// CodeEdit +// +// Created by Austin Condiff on 9/3/24. +// + +import SwiftUI + +struct SearchField: NSViewRepresentable { + @Binding var text: String + var placeholder: String + + init(_ placeholder: String, text: Binding) { + self.placeholder = placeholder + self._text = text + } + + func makeNSView(context: Context) -> NSSearchField { + let searchField = NSSearchField() + searchField.delegate = context.coordinator + searchField.placeholderString = placeholder + return searchField + } + + func updateNSView(_ nsView: NSSearchField, context: Context) { + nsView.stringValue = text + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + class Coordinator: NSObject, NSSearchFieldDelegate { + var parent: SearchField + + init(_ parent: SearchField) { + self.parent = parent + } + + func controlTextDidChange(_ obj: Notification) { + if let searchField = obj.object as? NSSearchField { + parent.text = searchField.stringValue + } + } + } +} + +#Preview { + SearchField("Search", text: .constant("Test")) +} diff --git a/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/Theme+FuzzySearchable.swift b/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/Theme+FuzzySearchable.swift new file mode 100644 index 000000000..88826b20f --- /dev/null +++ b/CodeEdit/Features/Settings/Pages/ThemeSettings/Models/Theme+FuzzySearchable.swift @@ -0,0 +1,14 @@ +// +// Theme+FuzzySearchable.swift +// CodeEdit +// +// Created by Tommy Ludwig on 14.08.24. +// + +import Foundation + +extension Theme: FuzzySearchable { + var searchableString: String { + return id + } +} diff --git a/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsView.swift b/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsView.swift index 3f327b43e..49371070a 100644 --- a/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsView.swift +++ b/CodeEdit/Features/Settings/Pages/ThemeSettings/ThemeSettingsView.swift @@ -18,85 +18,129 @@ struct ThemeSettingsView: View { var useDarkTerminalAppearance @State private var listView: Bool = false + @State private var themeSearchQuery: String = "" + @State private var filteredThemes: [Theme] = [] var body: some View { - SettingsForm { - Section { - changeThemeOnSystemAppearance - if settings.matchAppearance { - alwaysUseDarkTerminalAppearance - } - useThemeBackground - } - Section { - VStack(spacing: 0) { - if settings.matchAppearance { - Picker("", selection: $themeModel.selectedAppearance) { - ForEach(ThemeModel.ThemeSettingsAppearances.allCases, id: \.self) { tab in - Text(tab.rawValue) - .tag(tab) + VStack { + SettingsForm { + Section { + HStack(spacing: 10) { + SearchField("Search", text: $themeSearchQuery) + + Button { + // As discussed, the expected behavior is to duplicate the selected theme. + if let selectedTheme = themeModel.selectedTheme { + if let fileURL = selectedTheme.fileURL { + themeModel.duplicate(fileURL) + } + } + } label: { + Image(systemName: "plus") + } + .disabled(themeModel.selectedTheme == nil) + .help("Create a new Theme") + + MenuWithButtonStyle(systemImage: "ellipsis", menu: { + Group { + Button { + themeModel.importTheme() + } label: { + Text("Import Theme...") + } + Button { + // TODO: #1874 + } label: { + Text("Export All Custom Themes...") + }.disabled(true) } + }) + .padding(.horizontal, 5) + .help("Import or Export Custom Themes") + } + } + if themeSearchQuery.isEmpty { + Section { + changeThemeOnSystemAppearance + if settings.matchAppearance { + alwaysUseDarkTerminalAppearance } - .pickerStyle(.segmented) - .labelsHidden() - .padding(10) + useThemeBackground } + } + Section { VStack(spacing: 0) { - ForEach( - themeModel.selectedAppearance == .dark - ? themeModel.darkThemes - : themeModel.lightThemes - ) { theme in - Divider().padding(.horizontal, 10) - ThemeSettingsThemeRow( - theme: $themeModel.themes[themeModel.themes.firstIndex(of: theme)!], - active: themeModel.getThemeActive(theme) - ).id(theme) + ForEach(filteredThemes) { theme in + if let themeIndex = themeModel.themes.firstIndex(of: theme) { + Divider().padding(.horizontal, 10) + ThemeSettingsThemeRow( + theme: $themeModel.themes[themeIndex], + active: themeModel.getThemeActive(theme) + ).id(theme) + } } - ForEach( - themeModel.selectedAppearance == .dark - ? themeModel.lightThemes - : themeModel.darkThemes - ) { theme in - Divider().padding(.horizontal, 10) - ThemeSettingsThemeRow( - theme: $themeModel.themes[themeModel.themes.firstIndex(of: theme)!], - active: themeModel.getThemeActive(theme) - ).id(theme) + } + .padding(-10) + } footer: { + HStack { + Spacer() + Button("Import...") { + themeModel.importTheme() } } + .padding(.top, 10) } - .padding(-10) - } footer: { - HStack { - Spacer() - Button("Import...") { - themeModel.importTheme() + .sheet(item: $themeModel.detailsTheme) { + themeModel.isAdding = false + } content: { theme in + if let index = themeModel.themes.firstIndex(where: { + $0.fileURL?.absoluteString == theme.fileURL?.absoluteString + }) { + ThemeSettingsThemeDetails(theme: Binding( + get: { themeModel.themes[index] }, + set: { newValue in + themeModel.themes[index] = newValue + themeModel.save(newValue) + if settings.selectedTheme == theme.name { + themeModel.activateTheme(newValue) + } + } + )) } } - .padding(.top, 10) + .onAppear { + updateFilteredThemes() + } + .onChange(of: themeSearchQuery) { _ in + updateFilteredThemes() + } + .onChange(of: colorScheme) { newColorScheme in + updateFilteredThemes(overrideColorScheme: newColorScheme) + } } } - .sheet(item: $themeModel.detailsTheme) { - themeModel.isAdding = false - } content: { theme in - if let index = themeModel.themes.firstIndex(where: { - $0.fileURL?.absoluteString == theme.fileURL?.absoluteString - }) { - ThemeSettingsThemeDetails(theme: Binding( - get: { themeModel.themes[index] }, - set: { newValue in - themeModel.themes[index] = newValue - themeModel.save(newValue) - if settings.selectedTheme == theme.name { - themeModel.activateTheme(newValue) - } - } - )) - } + } + + /// Sorts themes by `colorScheme` and `themeSearchQuery`. + /// Dark mode themes appear before light themes if in dark mode, and vice versa. + private func updateFilteredThemes(overrideColorScheme: ColorScheme? = nil) { + // This check is necessary because, when calling `updateFilteredThemes` from within the + // `onChange` handler that monitors the `colorScheme`, there are cases where the function + // is invoked with outdated values of `colorScheme`. + let isDarkScheme = overrideColorScheme ?? colorScheme == .dark + + let themes: [Theme] = isDarkScheme + ? (themeModel.darkThemes + themeModel.lightThemes) + : (themeModel.lightThemes + themeModel.darkThemes) + Task { + filteredThemes = themeSearchQuery.isEmpty ? themes : await filterAndSortThemes(themes) } } + + private func filterAndSortThemes(_ themes: [Theme]) async -> [Theme] { + return await themes.fuzzySearch(query: themeSearchQuery).map { $1 } + } } private extension ThemeSettingsView {