Compare commits

...

2 Commits

Author SHA1 Message Date
Valdemar Lorenzen a3afbdc286 style: remove emoji picker divider lines 2026-10-21 11:58:18 +02:00
Valdemar Lorenzen 81cb5c5dfb feat: restyle emoji picker with sectioned tiles 2026-10-21 11:56:50 +02:00
4 changed files with 157 additions and 53 deletions
+3 -3
View File
@@ -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 and the shared action pill retain Conjure’s sunset palette, without divider lines around the grid.
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.
+108 -42
View File
@@ -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,15 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
footer.orientation = .horizontal
footer.alignment = .centerY
footer.spacing = 12
for child in [category, scroll, empty, footer] {
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),
scroll.topAnchor.constraint(equalTo: view.topAnchor),
scroll.leadingAnchor.constraint(equalTo: view.leadingAnchor),
scroll.trailingAnchor.constraint(equalTo: view.trailingAnchor),
scroll.bottomAnchor.constraint(equalTo: footer.topAnchor, constant: -4),
scroll.bottomAnchor.constraint(equalTo: footer.topAnchor, constant: -8),
empty.centerXAnchor.constraint(equalTo: scroll.centerXAnchor),
empty.centerYAnchor.constraint(equalTo: scroll.centerYAnchor),
footer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
@@ -104,27 +107,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 +153,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 +174,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 +242,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 +265,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 +305,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 +378,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
}
}
+38 -1
View File
@@ -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..<EmojiCatalog.all.count).first { index in
let nextRow = try #require((1..<grid.numberOfItems(inSection: 0)).first { index in
guard let frame = layout.layoutAttributesForItem(at: IndexPath(item: index, section: 0))?.frame else { return false }
return frame.minY > 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..<grid.numberOfSections).reduce(0) { $0 + grid.numberOfItems(inSection: $1) } == EmojiCatalog.all.count)
func expectCopied(_ emoji: String) {
picker.copySelection()
#expect(pasteboard.string(forType: .string) == emoji)
}
expectCopied("👩‍💻")
picker.moveSelection(by: 1)
#expect(picker.handleControlKey("j"))
expectCopied("😀")
#expect(picker.handleControlKey("k"))
expectCopied("🙏")
picker.moveSelection(by: -1)
#expect(picker.handleControlKey("j"))
expectCopied("🚀")
#expect(picker.handleControlKey("j"))
expectCopied(try #require(EmojiCatalog.all.first { !["👩‍💻", "🙏", "🚀", "😀"].contains($0.value) }).value)
#expect(picker.handleControlKey("k"))
expectCopied("🚀")
}
+8 -7
View File
@@ -71,8 +71,13 @@ import Testing
func expectVisible() throws {
picker.copySelection()
let index = try #require(ordered.firstIndex { $0.value == pasteboard.string(forType: .string) })
let path = favorites && index >= 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()