diff --git a/README.md b/README.md index acde3de..b30ce74 100644 --- a/README.md +++ b/README.md @@ -113,15 +113,15 @@ Rates are shared across queries and refreshed on the next currency query after t ## Emoji picker -Search for **Conjure Emoji**, identified by its centered πŸ€” icon, and open it. Browse the grid or search by name, keyword, `:shortcode:`, or emoji. Use the category menu to filter results or show **Recently Used**. Your last 30 selections are saved locally. Favorites appear first, followed by recent selections when browsing All Emoji. +Search for **Conjure Emoji**, identified by its centered πŸ€” icon, and open it. Browse labeled sections with emoji counts: **Favorites**, **Recently Used**, then the remaining categories. Each emoji appears once. Search by name, keyword, `:shortcode:`, or emoji; matching results appear in one section. The category menu in the footer filters results or shows **Recently Used**. Your last 30 selections are saved locally. -Square 64-point emoji tiles use compact 1-point grid spacing and show the same subtle rounded border used elsewhere in Conjure only when selected or hovered; the selected tile retains its accent fill. +Square 80-point emoji tiles have 8-point spacing and softly rounded, tinted surfaces. The selected tile uses Conjure’s amber fill and outline; hovering adds a subtle border. Section labels, search and footer dividers, and the shared action pill retain Conjure’s sunset palette. Use the arrow keys or Control-H/J/K/L to navigate left/down/up/right; the grid scrolls to keep the selected emoji visible. Return, clicking an emoji, or **Copy and Paste** copies it, dismisses Conjure, and tries to paste into the app that was focused when the launcher opened. Automatic pasting requires **System Settings β†’ Privacy & Security β†’ Accessibility β†’ Conjure**; macOS prompts if permission is missing. After granting it, select an emoji again. If permission is denied, the previous app is unavailable, or focus cannot be restored promptly, the emoji stays copied for manual Command-V. The Back chevron returns to the launcher and restores your search. If macOS keeps prompting despite an enabled Accessibility entry, quit Conjure, remove its stale entry with the minus button, add the exact app you launch (normally `/Applications/Conjure.app`), enable it, then reopen Conjure and retry. Avoid switching between installed and `.build` copies. For permission that survives rebuilds, configure certificate signing as described above. **Copy Emoji** followed by manual Command-V works without this permission. -Open **Actions** with Command-K for **Copy and Paste** (default), **Copy Emoji** (copy only, no Accessibility permission needed), **Favorite**, or **Remove Favorite**. Favorites are saved locally and grouped ahead of other emoji, with a small extra spacing break when browsing without a search. There are no star badges. +Open **Actions** with Command-K for **Copy and Paste** (default), **Copy Emoji** (copy only, no Accessibility permission needed), **Favorite**, or **Remove Favorite**. Favorites are saved locally and grouped in their own labeled section when browsing All Emoji without a search. There are no star badges. With Actions open, Escape or the Back chevron closes Actions first. Another Escape dismisses the launcher; another Back returns to Applications and restores your search. Command-W always dismisses the entire launcher, including the current Command and its Actions. Switching destinations also removes the previous Command's Actions completely. Copy and Copy and Paste dismiss the launcher without an intermediate Back step. diff --git a/Sources/Conjure/EmojiPickerController.swift b/Sources/Conjure/EmojiPickerController.swift index aaa4382..e22d97e 100644 --- a/Sources/Conjure/EmojiPickerController.swift +++ b/Sources/Conjure/EmojiPickerController.swift @@ -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)] = [] 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.. 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 } } diff --git a/Tests/ConjureKitTests/EmojiGridTests.swift b/Tests/ConjureKitTests/EmojiGridTests.swift index a0cafb5..40f3ec6 100644 --- a/Tests/ConjureKitTests/EmojiGridTests.swift +++ b/Tests/ConjureKitTests/EmojiGridTests.swift @@ -18,7 +18,7 @@ import Testing let grid = try #require(scroll.documentView as? NSCollectionView) let layout = try #require(grid.collectionViewLayout) let first = try #require(layout.layoutAttributesForItem(at: IndexPath(item: 0, section: 0))) - let nextRow = try #require((1.. first.frame.minY }) @@ -27,3 +27,40 @@ import Testing #expect(pasteboard.string(forType: .string) == EmojiCatalog.all[nextRow].value) print("Emoji grid: \(nextRow) columns at 750 points; down navigation follows the rendered row.") } + +@Test @MainActor func emojiNavigationCrossesFavoritesRecentAndCategorySections() throws { + _ = NSApplication.shared + let suite = "EmojiSections.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suite)! + let pasteboard = NSPasteboard.withUniqueName() + defer { defaults.removePersistentDomain(forName: suite); pasteboard.releaseGlobally() } + defaults.set(["πŸ‘©β€πŸ’»", "πŸ™"], forKey: "favoriteEmoji") + defaults.set(["πŸš€", "πŸ‘©β€πŸ’»", "πŸ˜€"], forKey: "recentEmoji") + let picker = EmojiPickerController(defaults: defaults, pasteboard: pasteboard) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 750, height: 400), + styleMask: [.borderless], backing: .buffered, defer: false) + window.contentView = picker.view + picker.view.layoutSubtreeIfNeeded() + let scroll = try #require(picker.view.subviews.compactMap { $0 as? NSScrollView }.first) + let grid = try #require(scroll.documentView as? NSCollectionView) + #expect(grid.numberOfItems(inSection: 0) == 2) + #expect(grid.numberOfItems(inSection: 1) == 2) + #expect((0..= favored.count - ? IndexPath(item: index - favored.count, section: 1) : IndexPath(item: index, section: 0) + var item = index + var section = 0 + while item >= grid.numberOfItems(inSection: section) { + item -= grid.numberOfItems(inSection: section) + section += 1 + } + let path = IndexPath(item: item, section: section) let frame = try #require(grid.collectionViewLayout?.layoutAttributesForItem(at: path)?.frame) try #require(scroll.documentVisibleRect.contains(frame)) } @@ -239,7 +244,7 @@ import Testing #expect(table.numberOfRows == 1) } -@Test @MainActor func emojiFavoritesPersistAndNavigationCrossesTheSpacingBreak() throws { +@Test @MainActor func emojiFavoritesPersistAndNavigationCrossesSections() throws { _ = NSApplication.shared let suite = "ConjureEmojiFavorites.\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suite)! @@ -254,9 +259,6 @@ import Testing query = "" picker.inputChanged() picker.view.layoutSubtreeIfNeeded() - let grid = try #require(picker.view.subviews.compactMap { $0 as? NSScrollView }.first?.documentView as? NSCollectionView) - #expect(grid.numberOfSections == 2) - #expect(grid.numberOfItems(inSection: 0) == 1) #expect(picker.handleControlKey("j")) picker.copySelection() #expect(pasteboard.string(forType: .string) == EmojiCatalog.all[0].value) @@ -271,7 +273,6 @@ import Testing #expect(picker.handleControlKey("k")) picker.toggleFavorite() #expect(defaults.stringArray(forKey: "favoriteEmoji") == []) - #expect(grid.numberOfSections == 1) query = "πŸ‘©β€πŸ’»" picker.inputChanged() picker.toggleFavorite()