diff --git a/Assets/icons.sketch b/Assets/icons.sketch index 2dd81ba..5ed2f45 100644 Binary files a/Assets/icons.sketch and b/Assets/icons.sketch differ diff --git a/Doughnut.xcodeproj/project.pbxproj b/Doughnut.xcodeproj/project.pbxproj index 3134c88..1511616 100644 --- a/Doughnut.xcodeproj/project.pbxproj +++ b/Doughnut.xcodeproj/project.pbxproj @@ -12,8 +12,10 @@ 5E0288D17E6858855545E02E /* Pods_DoughnutTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6BE29F62CFF7B122D5C39998 /* Pods_DoughnutTests.framework */; }; 6B0233BD27B2CA6500500E28 /* ControlMenuProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B0233BC27B2CA6500500E28 /* ControlMenuProvider.swift */; }; 6B0605C52788627D00A8A91E /* NSMenu+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B0605C42788627D00A8A91E /* NSMenu+Extensions.swift */; }; + 6B36624627CFB339008E1CA5 /* NSImage+Tint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B36624527CFB339008E1CA5 /* NSImage+Tint.swift */; }; 6B3A75F8278F44F500F25578 /* NSView+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B3A75F7278F44F500F25578 /* NSView+Extensions.swift */; }; 6B94DF4C278968F500BCB149 /* NSTableView+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B94DF4B278968F500BCB149 /* NSTableView+Extensions.swift */; }; + 6B96F45D27CE6F10001941BA /* PodcastSearchFiled.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B96F45C27CE6F10001941BA /* PodcastSearchFiled.swift */; }; 6B9C30BB27B5708300D462BE /* BaseTableView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B9C30BA27B5708300D462BE /* BaseTableView.swift */; }; 6B9E154727C9EA5F00C919D5 /* AppIcon_Big_Sur.icns in Resources */ = {isa = PBXBuildFile; fileRef = 6B9E154627C9EA5F00C919D5 /* AppIcon_Big_Sur.icns */; }; 6BA21C4F279D690700CD3672 /* WindowController+Toolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BA21C4E279D690700CD3672 /* WindowController+Toolbar.swift */; }; @@ -101,10 +103,12 @@ 5E1DC050EEE121FD033C44DF /* Pods-Doughnut.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Doughnut.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Doughnut/Pods-Doughnut.debug.xcconfig"; sourceTree = ""; }; 6B0233BC27B2CA6500500E28 /* ControlMenuProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlMenuProvider.swift; sourceTree = ""; }; 6B0605C42788627D00A8A91E /* NSMenu+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSMenu+Extensions.swift"; sourceTree = ""; }; + 6B36624527CFB339008E1CA5 /* NSImage+Tint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSImage+Tint.swift"; sourceTree = ""; }; 6B3A75F7278F44F500F25578 /* NSView+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSView+Extensions.swift"; sourceTree = ""; }; 6B3ACC982773555700CF1EF1 /* .swiftlint.yml */ = {isa = PBXFileReference; lastKnownFileType = text.yaml; path = .swiftlint.yml; sourceTree = ""; }; 6B730B732767A90900FB5F84 /* Doughnut-Release.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Doughnut-Release.entitlements"; sourceTree = ""; }; 6B94DF4B278968F500BCB149 /* NSTableView+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSTableView+Extensions.swift"; sourceTree = ""; }; + 6B96F45C27CE6F10001941BA /* PodcastSearchFiled.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PodcastSearchFiled.swift; sourceTree = ""; }; 6B9C30BA27B5708300D462BE /* BaseTableView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseTableView.swift; sourceTree = ""; }; 6B9E154627C9EA5F00C919D5 /* AppIcon_Big_Sur.icns */ = {isa = PBXFileReference; lastKnownFileType = image.icns; path = AppIcon_Big_Sur.icns; sourceTree = ""; }; 6BA21C4E279D690700CD3672 /* WindowController+Toolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WindowController+Toolbar.swift"; sourceTree = ""; }; @@ -237,6 +241,7 @@ 6B0605C42788627D00A8A91E /* NSMenu+Extensions.swift */, 6B94DF4B278968F500BCB149 /* NSTableView+Extensions.swift */, 6B3A75F7278F44F500F25578 /* NSView+Extensions.swift */, + 6B36624527CFB339008E1CA5 /* NSImage+Tint.swift */, ); name = AppKit; sourceTree = ""; @@ -374,6 +379,7 @@ 6B0233BC27B2CA6500500E28 /* ControlMenuProvider.swift */, 832A04331F76EBDC00C92D25 /* WindowController.swift */, 6BA21C4E279D690700CD3672 /* WindowController+Toolbar.swift */, + 6B96F45C27CE6F10001941BA /* PodcastSearchFiled.swift */, 832BB7511F95184700988AE8 /* Doughnut-Bridging-Header.h */, 838257D11F759F6F00DB4FD1 /* Assets.xcassets */, 831EBAD927CC11E700F212B4 /* Credits.rtf */, @@ -809,6 +815,7 @@ 83659AB81F7D8A0300E09833 /* Podcast.swift in Sources */, 837D52BE1F8E622200C17514 /* TasksViewController.swift in Sources */, 8379899B1F81616C00234577 /* SeekSlider.swift in Sources */, + 6B36624627CFB339008E1CA5 /* NSImage+Tint.swift in Sources */, 837068131F7BBD63007FE973 /* EpisodeCellView.swift in Sources */, 832FF90B1F8D60430065E593 /* DownloadManager.swift in Sources */, 83667DF41F76D22600F1ABC0 /* DetailViewController.swift in Sources */, @@ -824,6 +831,7 @@ 83235C012009472F00BC356F /* PrefGeneralViewController.swift in Sources */, 83235BFE2009472900BC356F /* Preference.swift in Sources */, 83FB4EB81F7BD9A1001CD842 /* Library.swift in Sources */, + 6B96F45D27CE6F10001941BA /* PodcastSearchFiled.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Doughnut/Assets.xcassets/PodcastFilter.imageset/Contents.json b/Doughnut/Assets.xcassets/PodcastFilter.imageset/Contents.json new file mode 100644 index 0000000..4a4d59d --- /dev/null +++ b/Doughnut/Assets.xcassets/PodcastFilter.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "PodcastFilter@2x.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Doughnut/Assets.xcassets/PodcastFilter.imageset/PodcastFilter@2x.png b/Doughnut/Assets.xcassets/PodcastFilter.imageset/PodcastFilter@2x.png new file mode 100644 index 0000000..2d8bfd0 Binary files /dev/null and b/Doughnut/Assets.xcassets/PodcastFilter.imageset/PodcastFilter@2x.png differ diff --git a/Doughnut/Assets.xcassets/PodcastFilterActive.imageset/Contents.json b/Doughnut/Assets.xcassets/PodcastFilterActive.imageset/Contents.json new file mode 100644 index 0000000..bc03fe1 --- /dev/null +++ b/Doughnut/Assets.xcassets/PodcastFilterActive.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "PodcastFilterActive@2x.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Doughnut/Assets.xcassets/PodcastFilterActive.imageset/PodcastFilterActive@2x.png b/Doughnut/Assets.xcassets/PodcastFilterActive.imageset/PodcastFilterActive@2x.png new file mode 100644 index 0000000..6ae1792 Binary files /dev/null and b/Doughnut/Assets.xcassets/PodcastFilterActive.imageset/PodcastFilterActive@2x.png differ diff --git a/Doughnut/Base.lproj/Main.storyboard b/Doughnut/Base.lproj/Main.storyboard index 4a401cd..561041d 100644 --- a/Doughnut/Base.lproj/Main.storyboard +++ b/Doughnut/Base.lproj/Main.storyboard @@ -460,15 +460,15 @@ Gw - + - + + + + + + + + + + + + - + - + @@ -507,7 +518,8 @@ Gw - + + @@ -571,19 +583,6 @@ Gw - - - - - - - - - - - - - diff --git a/Doughnut/PodcastSearchFiled.swift b/Doughnut/PodcastSearchFiled.swift new file mode 100644 index 0000000..ff7931e --- /dev/null +++ b/Doughnut/PodcastSearchFiled.swift @@ -0,0 +1,156 @@ +/* + * Doughnut Podcast Client + * Copyright (C) 2017 - 2022 Chris Dyer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit + +protocol PodcastSearchFiledDelegate: AnyObject { + + func podcastSearchFiledDidUpdate(withFilter filter: PodcastViewController.Filter) + +} + +final class PodcastSearchFiled: NSSearchField { + + weak var searchFieldDelegate: PodcastSearchFiledDelegate? + + private var filter: PodcastViewController.Filter = .all { + didSet { + updateFilteringButtonState() + searchFieldDelegate?.podcastSearchFiledDidUpdate(withFilter: filter) + } + } + + private var previousFilterCategory: PodcastViewController.Filter.Category = .newEpisodes + + // NSButtonCell has no methods fo tintColor, images have to be tinted manually + private var filterImage: NSImage? + private var filterImageActive: NSImage? + private var cancelImage: NSImage? + private var controlSelectedImage: NSImage? + + private static let searchButtonSize = CGSize(width: 32, height: 16) + + init() { + super.init(frame: .zero) + commonInit() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + commonInit() + } + + private func commonInit() { + updateIconImages() + + let searchFieldCell = cell as? NSSearchFieldCell + searchFieldCell?.searchButtonCell?.image = filterImage + searchFieldCell?.searchButtonCell?.alternateImage = filterImageActive + searchFieldCell?.searchButtonCell?.showsStateBy = [.contentsCellMask] + searchFieldCell?.searchButtonCell?.imageScaling = .scaleProportionallyDown + + searchFieldCell?.cancelButtonCell?.image = cancelImage + searchFieldCell?.cancelButtonCell?.alternateImage = controlSelectedImage + + target = self + action = #selector(onSearchTextChange(_:)) + + let menu = NSMenu() + menu.addItem(withTitle: "New Episodes", action: #selector(toggleFilterPodcasts(_:)), keyEquivalent: "") + for item in menu.items[0...] { + item.configureWithDefaultFont() + } + searchMenuTemplate = menu + } + + private func updateIconImages() { + // FIXME: refresh icon image on appearance or control tint color change. + filterImage = NSImage(named: "PodcastFilter")?.tinted(with: .secondaryLabelColor) + filterImageActive = NSImage(named: "PodcastFilterActive")?.tinted(with: .controlAccentColor) + + cancelImage = NSImage(named: NSImage.stopProgressFreestandingTemplateName)?.tinted(with: .secondaryLabelColor) + controlSelectedImage = NSImage(named: NSImage.stopProgressFreestandingTemplateName)?.tinted(with: .labelColor) + } + + override func draw(_ dirtyRect: NSRect) { + // This override is required, otherwise icon images won't update + super.draw(dirtyRect) + } + + override func rectForSearchButton(whenCentered isCentered: Bool) -> NSRect { + let originalRect = super.rectForSearchButton(whenCentered: isCentered) + return CGRect( + x: originalRect.origin.x, + y: originalRect.origin.y - (Self.searchButtonSize.height - originalRect.size.height) / 2, + width: Self.searchButtonSize.width, + height: Self.searchButtonSize.height + ) + } + + override func rectForSearchText(whenCentered isCentered: Bool) -> NSRect { + var rect = super.rectForSearchText(whenCentered: isCentered) + let searchButtonRect = super.rectForSearchButton(whenCentered: isCentered) + rect.origin.x += Self.searchButtonSize.width - searchButtonRect.width + rect.size.width -= Self.searchButtonSize.width - searchButtonRect.width + return rect + } + + override func mouseDown(with event: NSEvent) { + // If the click resides on the left half of the filter icon, perform a + // toggle against previous selected filter category, otherwise, fall back to + // the default behavior that shows the category menu. + let searchButtonRect = rectForSearchButton(whenCentered: centersPlaceholder) + let pointInSearchField = convert(event.locationInWindow, from: nil) + if + searchButtonRect.contains(pointInSearchField), + pointInSearchField.x <= searchButtonRect.midX, + filter.query.isEmpty + { + swap(&previousFilterCategory, &filter.category) + } else { + super.mouseDown(with: event) + } + } + + private func updateFilteringButtonState() { + let isActive = !filter.query.isEmpty || filter.category != .all + let searchFieldCell = cell as? NSSearchFieldCell + searchFieldCell?.searchButtonCell?.state = isActive ? .on : .off + needsDisplay = true + } + + @objc func toggleFilterPodcasts(_ sender: Any) { + previousFilterCategory = filter.category + filter.category = (filter.category == .newEpisodes) ? .all : .newEpisodes + } + + @objc func onSearchTextChange(_ sender: Any) { + filter.query = stringValue + } + + @objc func validateMenuItem(_ menuItem: NSMenuItem) -> Bool { + switch menuItem.action { + case #selector(toggleFilterPodcasts(_:)): + menuItem.state = filter.category == .newEpisodes ? .on : .off + return true + default: + return false + } + } + +} diff --git a/Doughnut/Utilities/NSImage+Tint.swift b/Doughnut/Utilities/NSImage+Tint.swift new file mode 100644 index 0000000..ec218fe --- /dev/null +++ b/Doughnut/Utilities/NSImage+Tint.swift @@ -0,0 +1,44 @@ +/* + * Doughnut Podcast Client + * Copyright (C) 2017 - 2022 Chris Dyer + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import AppKit + +// https://gist.github.com/usagimaru/c0a03ef86b5829fb9976b650ec2f1bf4 + +extension NSImage { + + func tinted(with tintColor: NSColor) -> NSImage { + if isTemplate == false { + return self + } + + let image = copy() as! NSImage + image.lockFocus() + + tintColor.set() + + let imageRect = NSRect(origin: .zero, size: image.size) + imageRect.fill(using: .sourceIn) + + image.unlockFocus() + image.isTemplate = false + + return image + } + +} diff --git a/Doughnut/Utilities/NSMenu+Extensions.swift b/Doughnut/Utilities/NSMenu+Extensions.swift index b73478a..8a0397c 100644 --- a/Doughnut/Utilities/NSMenu+Extensions.swift +++ b/Doughnut/Utilities/NSMenu+Extensions.swift @@ -63,4 +63,13 @@ extension NSMenuItem { return menu?.menuType } + func configureWithDefaultFont() { + attributedTitle = NSAttributedString( + string: title, + attributes: [ + .font: NSFont.controlContentFont(ofSize: NSFont.systemFontSize), + ] + ) + } + } diff --git a/Doughnut/View Controllers/PodcastViewController.swift b/Doughnut/View Controllers/PodcastViewController.swift index 6679ffc..eb6156b 100644 --- a/Doughnut/View Controllers/PodcastViewController.swift +++ b/Doughnut/View Controllers/PodcastViewController.swift @@ -28,16 +28,27 @@ final class PodcastViewController: NSViewController, NSTableViewDelegate, NSTabl case recentEpisodes = "Recent Episode" } - enum Filter { - case all - case newEpisodes + struct Filter: Equatable { + enum Category: Equatable { + case all + case newEpisodes + } + + var category: Category + var query: String + + static var all: Self { + return Self(category: .all, query: "") + } } var podcasts = [Podcast]() @IBOutlet var tableView: NSTableView! + @IBOutlet var sortView: NSView! - @IBOutlet var filteringButton: NSButton! + @IBOutlet var moreButton: NSButton! + @IBOutlet var searchField: PodcastSearchFiled! private var sortingMenuProvider: SortingMenuProvider { return SortingMenuProvider.Shared.podcasts @@ -47,9 +58,8 @@ final class PodcastViewController: NSViewController, NSTableViewDelegate, NSTabl return tableView.enclosingScrollView! } - var filter: Filter = .all { + private var filter: Filter = .all { didSet { - updateFilteringButtonState() reloadPodcasts() } } @@ -92,12 +102,15 @@ final class PodcastViewController: NSViewController, NSTableViewDelegate, NSTabl sortingMenuProvider.sortParam = sortBy.rawValue sortingMenuProvider.sortDirection = sortDirection sortingMenuProvider.delegate = self + + searchField.searchFieldDelegate = self } override func viewDidAppear() { super.viewDidAppear() - updateFilteringButtonState() + let moreButtonCell = moreButton.cell as? NSButtonCell + moreButtonCell?.highlightsBy = [] tableScrollView.automaticallyAdjustsContentInsets = false @@ -116,15 +129,6 @@ final class PodcastViewController: NSViewController, NSTableViewDelegate, NSTabl ) } - private func updateFilteringButtonState() { - filteringButton.contentTintColor = filter == .all - ? .secondaryLabelColor - : .controlAccentColor - filteringButton.image = filter == .all - ? NSImage(named: "FilterInactive") - : NSImage(named: "FilterActive") - } - func reloadPodcasts() { let previousSelectedPodcastIds = tableView.selectedRowIndexes.compactMap { return podcasts[$0].id @@ -133,13 +137,22 @@ final class PodcastViewController: NSViewController, NSTableViewDelegate, NSTabl podcasts = Library.global.podcasts podcasts = podcasts.filter { podcast -> Bool in - if filter == .newEpisodes { + if filter.category == .newEpisodes { return podcast.unplayedCount > 0 } else { return true } } + if !filter.query.isEmpty { + let query = filter.query.lowercased().filter { !$0.isWhitespace } + podcasts = podcasts.filter { podcast in + return podcast.title + .lowercased().filter { !$0.isWhitespace } + .contains(query) + } + } + // Sort into ascending order podcasts.sort { (a, b) -> Bool in switch sortBy { @@ -242,10 +255,6 @@ final class PodcastViewController: NSViewController, NSTableViewDelegate, NSTabl // MARK: - Actions - @IBAction func toggleFilterPodcasts(_ sender: Any) { - filter = (filter == .newEpisodes) ? .all : .newEpisodes - } - @IBAction func reloadPodcast(_ sender: Any) { let podcasts = activePodcastsForAction() assert(podcasts.count == 1) @@ -349,9 +358,6 @@ final class PodcastViewController: NSViewController, NSTableViewDelegate, NSTabl let podcasts = activePodcastsForAction() switch menuItem.action { - case #selector(toggleFilterPodcasts(_:)): - menuItem.state = filter == .newEpisodes ? .on : .off - return true case #selector(reloadPodcast(_:)): return podcasts.count == 1 case #selector(getInfo(_:)): @@ -378,6 +384,14 @@ final class PodcastViewController: NSViewController, NSTableViewDelegate, NSTabl } +extension PodcastViewController: PodcastSearchFiledDelegate { + + func podcastSearchFiledDidUpdate(withFilter filter: Filter) { + self.filter = filter + } + +} + extension PodcastViewController: NSMenuDelegate { func menuNeedsUpdate(_ menu: NSMenu) { diff --git a/Doughnut/Views/SortingMenuProvider.swift b/Doughnut/Views/SortingMenuProvider.swift index 45be4e9..563ed71 100644 --- a/Doughnut/Views/SortingMenuProvider.swift +++ b/Doughnut/Views/SortingMenuProvider.swift @@ -107,12 +107,7 @@ final class SortingMenuProvider { // Ensure menuItems' title font is consistent with normal menus for // recessed pull-down button. for item in sortMenu.items[1...] { - item.attributedTitle = NSAttributedString( - string: item.title, - attributes: [ - .font: NSFont.controlContentFont(ofSize: NSFont.systemFontSize), - ] - ) + item.configureWithDefaultFont() } }