feat: restyle emoji picker with sectioned tiles
This commit is contained in:
@@ -4,14 +4,14 @@ import ConjureKit
|
||||
@MainActor
|
||||
final class EmojiPickerController: NSViewController, NSCollectionViewDataSource, NSCollectionViewDelegateFlowLayout {
|
||||
private let collection = NSCollectionView()
|
||||
private let layout = NSCollectionViewFlowLayout()
|
||||
private let category = NSPopUpButton()
|
||||
private let status = NSTextField(labelWithString: "")
|
||||
private let empty = NSTextField(labelWithString: "No matching emoji")
|
||||
private let copyButton = NSButton(title: "Copy and Paste", target: nil, action: nil)
|
||||
private let actionsButton = NSButton(title: "Actions", target: nil, action: nil)
|
||||
private let actions = ApplicationActionsController()
|
||||
private var favoriteCount = 0
|
||||
private var splitFavorites: Bool { favoriteCount > 0 && favoriteCount < results.count }
|
||||
private var sections: [(title: String, range: Range<Int>)] = []
|
||||
private let defaults: UserDefaults
|
||||
private let pasteboard: NSPasteboard
|
||||
private var results: [Emoji] = []
|
||||
@@ -36,16 +36,21 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
|
||||
category.setAccessibilityLabel("Emoji category")
|
||||
category.target = self
|
||||
category.action = #selector(categoryChanged)
|
||||
let layout = NSCollectionViewFlowLayout()
|
||||
layout.itemSize = NSSize(width: 64, height: 64)
|
||||
layout.minimumInteritemSpacing = 1
|
||||
layout.minimumLineSpacing = 1
|
||||
layout.sectionInset = NSEdgeInsets(top: LauncherScrollView.fadeHeight, left: 16, bottom: 12, right: 16)
|
||||
category.font = .systemFont(ofSize: 12)
|
||||
category.isBordered = false
|
||||
category.contentTintColor = SunsetPalette.secondaryText
|
||||
category.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||
layout.itemSize = NSSize(width: 80, height: 80)
|
||||
layout.minimumInteritemSpacing = 8
|
||||
layout.minimumLineSpacing = 8
|
||||
collection.collectionViewLayout = layout
|
||||
collection.backgroundColors = [.clear]
|
||||
collection.dataSource = self
|
||||
collection.delegate = self
|
||||
collection.register(EmojiItem.self, forItemWithIdentifier: NSUserInterfaceItemIdentifier("emoji"))
|
||||
collection.register(EmojiSectionHeader.self,
|
||||
forSupplementaryViewOfKind: NSCollectionView.elementKindSectionHeader,
|
||||
withIdentifier: NSUserInterfaceItemIdentifier("section"))
|
||||
collection.setAccessibilityLabel("Emoji grid")
|
||||
let scroll = LauncherScrollView()
|
||||
scroll.documentView = collection
|
||||
@@ -64,7 +69,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
|
||||
actionsButton.target = self
|
||||
actionsButton.action = #selector(showActions)
|
||||
let pill = ActionPill(primaryButton: copyButton, actionsButton: actionsButton)
|
||||
let footer = NSStackView(views: [status, NSView(), pill])
|
||||
let footer = NSStackView(views: [category, status, NSView(), pill])
|
||||
actions.closed = { [weak self] in
|
||||
self?.copyButton.keyEquivalent = "\r"
|
||||
self?.focusSearch?()
|
||||
@@ -72,17 +77,31 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
|
||||
footer.orientation = .horizontal
|
||||
footer.alignment = .centerY
|
||||
footer.spacing = 12
|
||||
for child in [category, scroll, empty, footer] {
|
||||
let headerDivider = NSView()
|
||||
let footerDivider = NSView()
|
||||
for divider in [headerDivider, footerDivider] {
|
||||
divider.wantsLayer = true
|
||||
divider.layer?.backgroundColor = SunsetPalette.selectionBorder.cgColor
|
||||
divider.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(divider)
|
||||
}
|
||||
for child in [scroll, empty, footer] {
|
||||
child.translatesAutoresizingMaskIntoConstraints = false
|
||||
view.addSubview(child)
|
||||
}
|
||||
NSLayoutConstraint.activate([
|
||||
category.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
category.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
|
||||
scroll.topAnchor.constraint(equalTo: category.bottomAnchor, constant: 4),
|
||||
headerDivider.topAnchor.constraint(equalTo: view.topAnchor),
|
||||
headerDivider.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
headerDivider.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
headerDivider.heightAnchor.constraint(equalToConstant: 0.5),
|
||||
scroll.topAnchor.constraint(equalTo: headerDivider.bottomAnchor),
|
||||
scroll.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
scroll.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
scroll.bottomAnchor.constraint(equalTo: footer.topAnchor, constant: -4),
|
||||
scroll.bottomAnchor.constraint(equalTo: footerDivider.topAnchor),
|
||||
footerDivider.bottomAnchor.constraint(equalTo: footer.topAnchor, constant: -8),
|
||||
footerDivider.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
footerDivider.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
footerDivider.heightAnchor.constraint(equalToConstant: 0.5),
|
||||
empty.centerXAnchor.constraint(equalTo: scroll.centerXAnchor),
|
||||
empty.centerYAnchor.constraint(equalTo: scroll.centerYAnchor),
|
||||
footer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
|
||||
@@ -104,27 +123,43 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
|
||||
|
||||
func inputChanged(preservingSelection: Bool = false) {
|
||||
let previous = preservingSelection && results.indices.contains(selected) ? results[selected].value : nil
|
||||
let query = (input?() ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let recent = defaults.stringArray(forKey: "recentEmoji") ?? []
|
||||
let matches = EmojiCatalog.search(input?() ?? "", category: category.indexOfSelectedItem > 1 ? category.titleOfSelectedItem : nil)
|
||||
let favorites = defaults.stringArray(forKey: "favoriteEmoji") ?? []
|
||||
let favoriteSet = Set(favorites)
|
||||
var matches = EmojiCatalog.search(query, category: category.indexOfSelectedItem > 1 ? category.titleOfSelectedItem : nil)
|
||||
results.removeAll(keepingCapacity: true)
|
||||
sections.removeAll(keepingCapacity: true)
|
||||
if category.indexOfSelectedItem == 1 {
|
||||
results = recent.compactMap { value in matches.first { $0.value == value } }
|
||||
} else if category.indexOfSelectedItem == 0, (input?() ?? "").isEmpty {
|
||||
let recentSet = Set(recent)
|
||||
results = recent.compactMap { value in matches.first { $0.value == value } }
|
||||
+ matches.filter { !recentSet.contains($0.value) }
|
||||
} else {
|
||||
results = matches
|
||||
let byValue = Dictionary(uniqueKeysWithValues: matches.map { ($0.value, $0) })
|
||||
matches = recent.compactMap { byValue[$0] }
|
||||
}
|
||||
if !query.isEmpty || category.indexOfSelectedItem > 0 {
|
||||
addSection(query.isEmpty ? category.titleOfSelectedItem! : "Search Results",
|
||||
emoji: matches.filter { favoriteSet.contains($0.value) } + matches.filter { !favoriteSet.contains($0.value) })
|
||||
} else {
|
||||
let byValue = Dictionary(uniqueKeysWithValues: matches.map { ($0.value, $0) })
|
||||
addSection("Favorites", emoji: favorites.compactMap { byValue[$0] })
|
||||
addSection("Recently Used", emoji: recent.compactMap { favoriteSet.contains($0) ? nil : byValue[$0] })
|
||||
let promoted = favoriteSet.union(recent)
|
||||
let categories = Dictionary(grouping: matches.filter { !promoted.contains($0.value) }, by: \.category)
|
||||
for title in EmojiCatalog.categories {
|
||||
addSection(title, emoji: categories[title] ?? [])
|
||||
}
|
||||
}
|
||||
let favorites = Set(defaults.stringArray(forKey: "favoriteEmoji") ?? [])
|
||||
let favored = results.filter { favorites.contains($0.value) }
|
||||
results = favored + results.filter { !favorites.contains($0.value) }
|
||||
favoriteCount = (input?() ?? "").trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? favored.count : 0
|
||||
selected = previous.flatMap { value in results.firstIndex { $0.value == value } } ?? 0
|
||||
collection.reloadData()
|
||||
collection.scroll(NSPoint.zero)
|
||||
updateSelection()
|
||||
}
|
||||
|
||||
private func addSection(_ title: String, emoji: [Emoji]) {
|
||||
guard !emoji.isEmpty else { return }
|
||||
let start = results.count
|
||||
results.append(contentsOf: emoji)
|
||||
sections.append((title, start..<results.count))
|
||||
}
|
||||
|
||||
func moveSelection(by offset: Int) {
|
||||
guard !results.isEmpty else { return }
|
||||
let old = selected
|
||||
@@ -134,14 +169,17 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
|
||||
collection.scrollToItems(at: [indexPath(for: selected)], scrollPosition: .nearestHorizontalEdge)
|
||||
}
|
||||
|
||||
private var columns: Int { max(1, Int((collection.bounds.width - 32 + 1) / 65)) }
|
||||
|
||||
private func indexPath(for index: Int) -> IndexPath {
|
||||
splitFavorites && index >= favoriteCount
|
||||
? IndexPath(item: index - favoriteCount, section: 1) : IndexPath(item: index, section: 0)
|
||||
private var columns: Int {
|
||||
max(1, Int((collection.bounds.width - 32 + layout.minimumInteritemSpacing)
|
||||
/ (layout.itemSize.width + layout.minimumInteritemSpacing)))
|
||||
}
|
||||
|
||||
private func index(for path: IndexPath) -> Int { path.item + (path.section == 1 ? favoriteCount : 0) }
|
||||
private func indexPath(for index: Int) -> IndexPath {
|
||||
let section = sections.firstIndex { $0.range.contains(index) }!
|
||||
return IndexPath(item: index - sections[section].range.lowerBound, section: section)
|
||||
}
|
||||
|
||||
private func index(for path: IndexPath) -> Int { sections[path.section].range.lowerBound + path.item }
|
||||
|
||||
private func moveVertically(_ direction: Int) {
|
||||
guard !results.isEmpty else { return }
|
||||
@@ -152,13 +190,13 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
|
||||
if direction < 0 {
|
||||
if path.item >= columns { target = IndexPath(item: path.item - columns, section: path.section) }
|
||||
else if path.section > 0 {
|
||||
let previousCount = favoriteCount
|
||||
target = IndexPath(item: min(((previousCount - 1) / columns) * columns + column, previousCount - 1), section: 0)
|
||||
let previousCount = sections[path.section - 1].range.count
|
||||
target = IndexPath(item: min(((previousCount - 1) / columns) * columns + column, previousCount - 1), section: path.section - 1)
|
||||
}
|
||||
} else if path.item / columns < (count - 1) / columns {
|
||||
target = IndexPath(item: min(path.item + columns, count - 1), section: path.section)
|
||||
} else if splitFavorites && path.section == 0 {
|
||||
target = IndexPath(item: min(column, results.count - favoriteCount - 1), section: 1)
|
||||
} else if path.section + 1 < sections.count {
|
||||
target = IndexPath(item: min(column, sections[path.section + 1].range.count - 1), section: path.section + 1)
|
||||
}
|
||||
moveSelection(by: index(for: target) - selected)
|
||||
}
|
||||
@@ -220,7 +258,9 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
|
||||
copyButton.isEnabled = !results.isEmpty
|
||||
actionsButton.isEnabled = !results.isEmpty
|
||||
status.stringValue = results.isEmpty ? "0 emoji" : "\(results[selected].name) · \(results.count) emoji"
|
||||
(collection.item(at: indexPath(for: selected)) as? EmojiItem)?.highlight(true)
|
||||
if !results.isEmpty {
|
||||
(collection.item(at: indexPath(for: selected)) as? EmojiItem)?.highlight(true)
|
||||
}
|
||||
}
|
||||
|
||||
@objc func copySelection() { copySelection(pasting: false) }
|
||||
@@ -241,14 +281,28 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
|
||||
if pasting { pasteRequested?() } else { copied?() }
|
||||
}
|
||||
|
||||
func numberOfSections(in collectionView: NSCollectionView) -> Int { splitFavorites ? 2 : 1 }
|
||||
func numberOfSections(in collectionView: NSCollectionView) -> Int { sections.count }
|
||||
|
||||
func collectionView(_ collectionView: NSCollectionView, numberOfItemsInSection section: Int) -> Int {
|
||||
splitFavorites ? (section == 0 ? favoriteCount : results.count - favoriteCount) : results.count
|
||||
sections[section].range.count
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: NSCollectionView, layout collectionViewLayout: NSCollectionViewLayout, insetForSectionAt section: Int) -> NSEdgeInsets {
|
||||
NSEdgeInsets(top: section == 1 ? 18 : LauncherScrollView.fadeHeight, left: 16, bottom: splitFavorites && section == 0 ? 0 : 12, right: 16)
|
||||
NSEdgeInsets(top: 0, left: 16, bottom: section == sections.count - 1 ? 16 : 0, right: 16)
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: NSCollectionView, layout collectionViewLayout: NSCollectionViewLayout,
|
||||
referenceSizeForHeaderInSection section: Int) -> NSSize {
|
||||
NSSize(width: collectionView.bounds.width, height: section == 0 ? LauncherScrollView.fadeHeight + 24 : 40)
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: NSCollectionView, viewForSupplementaryElementOfKind kind: NSCollectionView.SupplementaryElementKind,
|
||||
at indexPath: IndexPath) -> NSView {
|
||||
let header = collectionView.makeSupplementaryView(ofKind: kind,
|
||||
withIdentifier: NSUserInterfaceItemIdentifier("section"), for: indexPath) as! EmojiSectionHeader
|
||||
let section = sections[indexPath.section]
|
||||
header.setTitle(section.title, count: section.range.count)
|
||||
return header
|
||||
}
|
||||
|
||||
func collectionView(_ collectionView: NSCollectionView, itemForRepresentedObjectAt indexPath: IndexPath) -> NSCollectionViewItem {
|
||||
@@ -267,24 +321,50 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class EmojiSectionHeader: NSView {
|
||||
private let label = NSTextField(labelWithString: "")
|
||||
|
||||
override init(frame frameRect: NSRect) {
|
||||
super.init(frame: frameRect)
|
||||
label.translatesAutoresizingMaskIntoConstraints = false
|
||||
addSubview(label)
|
||||
NSLayoutConstraint.activate([
|
||||
label.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16),
|
||||
label.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8),
|
||||
])
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) { fatalError("Conjure creates emoji headers programmatically") }
|
||||
|
||||
func setTitle(_ title: String, count: Int) {
|
||||
let text = NSMutableAttributedString(string: title, attributes: [
|
||||
.font: NSFont.systemFont(ofSize: 13), .foregroundColor: SunsetPalette.secondaryText,
|
||||
])
|
||||
text.append(NSAttributedString(string: " \(count)", attributes: [
|
||||
.font: NSFont.systemFont(ofSize: 12), .foregroundColor: SunsetPalette.secondaryText.withAlphaComponent(0.55),
|
||||
]))
|
||||
label.attributedStringValue = text
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private final class EmojiItem: NSCollectionViewItem {
|
||||
let button = EmojiButton()
|
||||
var clicked: (() -> Void)?
|
||||
|
||||
override func loadView() {
|
||||
button.font = .systemFont(ofSize: 32)
|
||||
button.font = .systemFont(ofSize: 28)
|
||||
button.isBordered = false
|
||||
button.target = self
|
||||
button.action = #selector(copyEmoji)
|
||||
button.wantsLayer = true
|
||||
button.layer?.cornerRadius = 10
|
||||
button.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.22).cgColor
|
||||
view = button
|
||||
}
|
||||
|
||||
func highlight(_ selected: Bool) {
|
||||
button.layer?.backgroundColor = selected ? SunsetPalette.accent.withAlphaComponent(0.22).cgColor : nil
|
||||
button.layer?.backgroundColor = (selected ? SunsetPalette.selectionFill : SunsetPalette.text.withAlphaComponent(0.045)).cgColor
|
||||
button.setAccessibilitySelected(selected)
|
||||
button.selected = selected
|
||||
}
|
||||
@@ -314,5 +394,7 @@ private final class EmojiButton: NSButton {
|
||||
|
||||
private func updateBorder() {
|
||||
layer?.borderWidth = selected || hovered ? 0.5 : 0
|
||||
layer?.borderColor = (selected ? SunsetPalette.accent.withAlphaComponent(0.8)
|
||||
: SunsetPalette.secondaryText.withAlphaComponent(0.35)).cgColor
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user