Compare commits
2 Commits
abdaa70c0d
...
a3afbdc286
| Author | SHA1 | Date | |
|---|---|---|---|
| a3afbdc286 | |||
| 81cb5c5dfb |
@@ -113,15 +113,15 @@ Rates are shared across queries and refreshed on the next currency query after t
|
|||||||
|
|
||||||
## Emoji picker
|
## 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.
|
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.
|
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.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,14 @@ import ConjureKit
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class EmojiPickerController: NSViewController, NSCollectionViewDataSource, NSCollectionViewDelegateFlowLayout {
|
final class EmojiPickerController: NSViewController, NSCollectionViewDataSource, NSCollectionViewDelegateFlowLayout {
|
||||||
private let collection = NSCollectionView()
|
private let collection = NSCollectionView()
|
||||||
|
private let layout = NSCollectionViewFlowLayout()
|
||||||
private let category = NSPopUpButton()
|
private let category = NSPopUpButton()
|
||||||
private let status = NSTextField(labelWithString: "")
|
private let status = NSTextField(labelWithString: "")
|
||||||
private let empty = NSTextField(labelWithString: "No matching emoji")
|
private let empty = NSTextField(labelWithString: "No matching emoji")
|
||||||
private let copyButton = NSButton(title: "Copy and Paste", target: nil, action: nil)
|
private let copyButton = NSButton(title: "Copy and Paste", target: nil, action: nil)
|
||||||
private let actionsButton = NSButton(title: "Actions", target: nil, action: nil)
|
private let actionsButton = NSButton(title: "Actions", target: nil, action: nil)
|
||||||
private let actions = ApplicationActionsController()
|
private let actions = ApplicationActionsController()
|
||||||
private var favoriteCount = 0
|
private var sections: [(title: String, range: Range<Int>)] = []
|
||||||
private var splitFavorites: Bool { favoriteCount > 0 && favoriteCount < results.count }
|
|
||||||
private let defaults: UserDefaults
|
private let defaults: UserDefaults
|
||||||
private let pasteboard: NSPasteboard
|
private let pasteboard: NSPasteboard
|
||||||
private var results: [Emoji] = []
|
private var results: [Emoji] = []
|
||||||
@@ -36,16 +36,21 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
|
|||||||
category.setAccessibilityLabel("Emoji category")
|
category.setAccessibilityLabel("Emoji category")
|
||||||
category.target = self
|
category.target = self
|
||||||
category.action = #selector(categoryChanged)
|
category.action = #selector(categoryChanged)
|
||||||
let layout = NSCollectionViewFlowLayout()
|
category.font = .systemFont(ofSize: 12)
|
||||||
layout.itemSize = NSSize(width: 64, height: 64)
|
category.isBordered = false
|
||||||
layout.minimumInteritemSpacing = 1
|
category.contentTintColor = SunsetPalette.secondaryText
|
||||||
layout.minimumLineSpacing = 1
|
category.setContentCompressionResistancePriority(.required, for: .horizontal)
|
||||||
layout.sectionInset = NSEdgeInsets(top: LauncherScrollView.fadeHeight, left: 16, bottom: 12, right: 16)
|
layout.itemSize = NSSize(width: 80, height: 80)
|
||||||
|
layout.minimumInteritemSpacing = 8
|
||||||
|
layout.minimumLineSpacing = 8
|
||||||
collection.collectionViewLayout = layout
|
collection.collectionViewLayout = layout
|
||||||
collection.backgroundColors = [.clear]
|
collection.backgroundColors = [.clear]
|
||||||
collection.dataSource = self
|
collection.dataSource = self
|
||||||
collection.delegate = self
|
collection.delegate = self
|
||||||
collection.register(EmojiItem.self, forItemWithIdentifier: NSUserInterfaceItemIdentifier("emoji"))
|
collection.register(EmojiItem.self, forItemWithIdentifier: NSUserInterfaceItemIdentifier("emoji"))
|
||||||
|
collection.register(EmojiSectionHeader.self,
|
||||||
|
forSupplementaryViewOfKind: NSCollectionView.elementKindSectionHeader,
|
||||||
|
withIdentifier: NSUserInterfaceItemIdentifier("section"))
|
||||||
collection.setAccessibilityLabel("Emoji grid")
|
collection.setAccessibilityLabel("Emoji grid")
|
||||||
let scroll = LauncherScrollView()
|
let scroll = LauncherScrollView()
|
||||||
scroll.documentView = collection
|
scroll.documentView = collection
|
||||||
@@ -64,7 +69,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
|
|||||||
actionsButton.target = self
|
actionsButton.target = self
|
||||||
actionsButton.action = #selector(showActions)
|
actionsButton.action = #selector(showActions)
|
||||||
let pill = ActionPill(primaryButton: copyButton, actionsButton: actionsButton)
|
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
|
actions.closed = { [weak self] in
|
||||||
self?.copyButton.keyEquivalent = "\r"
|
self?.copyButton.keyEquivalent = "\r"
|
||||||
self?.focusSearch?()
|
self?.focusSearch?()
|
||||||
@@ -72,17 +77,15 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
|
|||||||
footer.orientation = .horizontal
|
footer.orientation = .horizontal
|
||||||
footer.alignment = .centerY
|
footer.alignment = .centerY
|
||||||
footer.spacing = 12
|
footer.spacing = 12
|
||||||
for child in [category, scroll, empty, footer] {
|
for child in [scroll, empty, footer] {
|
||||||
child.translatesAutoresizingMaskIntoConstraints = false
|
child.translatesAutoresizingMaskIntoConstraints = false
|
||||||
view.addSubview(child)
|
view.addSubview(child)
|
||||||
}
|
}
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
category.topAnchor.constraint(equalTo: view.topAnchor),
|
scroll.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
category.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
|
|
||||||
scroll.topAnchor.constraint(equalTo: category.bottomAnchor, constant: 4),
|
|
||||||
scroll.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
scroll.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
scroll.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
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.centerXAnchor.constraint(equalTo: scroll.centerXAnchor),
|
||||||
empty.centerYAnchor.constraint(equalTo: scroll.centerYAnchor),
|
empty.centerYAnchor.constraint(equalTo: scroll.centerYAnchor),
|
||||||
footer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
|
footer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
|
||||||
@@ -104,27 +107,43 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
|
|||||||
|
|
||||||
func inputChanged(preservingSelection: Bool = false) {
|
func inputChanged(preservingSelection: Bool = false) {
|
||||||
let previous = preservingSelection && results.indices.contains(selected) ? results[selected].value : nil
|
let previous = preservingSelection && results.indices.contains(selected) ? results[selected].value : nil
|
||||||
|
let query = (input?() ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
let recent = defaults.stringArray(forKey: "recentEmoji") ?? []
|
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 {
|
if category.indexOfSelectedItem == 1 {
|
||||||
results = recent.compactMap { value in matches.first { $0.value == value } }
|
let byValue = Dictionary(uniqueKeysWithValues: matches.map { ($0.value, $0) })
|
||||||
} else if category.indexOfSelectedItem == 0, (input?() ?? "").isEmpty {
|
matches = recent.compactMap { byValue[$0] }
|
||||||
let recentSet = Set(recent)
|
}
|
||||||
results = recent.compactMap { value in matches.first { $0.value == value } }
|
if !query.isEmpty || category.indexOfSelectedItem > 0 {
|
||||||
+ matches.filter { !recentSet.contains($0.value) }
|
addSection(query.isEmpty ? category.titleOfSelectedItem! : "Search Results",
|
||||||
} else {
|
emoji: matches.filter { favoriteSet.contains($0.value) } + matches.filter { !favoriteSet.contains($0.value) })
|
||||||
results = matches
|
} 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
|
selected = previous.flatMap { value in results.firstIndex { $0.value == value } } ?? 0
|
||||||
collection.reloadData()
|
collection.reloadData()
|
||||||
collection.scroll(NSPoint.zero)
|
collection.scroll(NSPoint.zero)
|
||||||
updateSelection()
|
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) {
|
func moveSelection(by offset: Int) {
|
||||||
guard !results.isEmpty else { return }
|
guard !results.isEmpty else { return }
|
||||||
let old = selected
|
let old = selected
|
||||||
@@ -134,14 +153,17 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
|
|||||||
collection.scrollToItems(at: [indexPath(for: selected)], scrollPosition: .nearestHorizontalEdge)
|
collection.scrollToItems(at: [indexPath(for: selected)], scrollPosition: .nearestHorizontalEdge)
|
||||||
}
|
}
|
||||||
|
|
||||||
private var columns: Int { max(1, Int((collection.bounds.width - 32 + 1) / 65)) }
|
private var columns: Int {
|
||||||
|
max(1, Int((collection.bounds.width - 32 + layout.minimumInteritemSpacing)
|
||||||
private func indexPath(for index: Int) -> IndexPath {
|
/ (layout.itemSize.width + layout.minimumInteritemSpacing)))
|
||||||
splitFavorites && index >= favoriteCount
|
|
||||||
? IndexPath(item: index - favoriteCount, section: 1) : IndexPath(item: index, section: 0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
private func moveVertically(_ direction: Int) {
|
||||||
guard !results.isEmpty else { return }
|
guard !results.isEmpty else { return }
|
||||||
@@ -152,13 +174,13 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
|
|||||||
if direction < 0 {
|
if direction < 0 {
|
||||||
if path.item >= columns { target = IndexPath(item: path.item - columns, section: path.section) }
|
if path.item >= columns { target = IndexPath(item: path.item - columns, section: path.section) }
|
||||||
else if path.section > 0 {
|
else if path.section > 0 {
|
||||||
let previousCount = favoriteCount
|
let previousCount = sections[path.section - 1].range.count
|
||||||
target = IndexPath(item: min(((previousCount - 1) / columns) * columns + column, previousCount - 1), section: 0)
|
target = IndexPath(item: min(((previousCount - 1) / columns) * columns + column, previousCount - 1), section: path.section - 1)
|
||||||
}
|
}
|
||||||
} else if path.item / columns < (count - 1) / columns {
|
} else if path.item / columns < (count - 1) / columns {
|
||||||
target = IndexPath(item: min(path.item + columns, count - 1), section: path.section)
|
target = IndexPath(item: min(path.item + columns, count - 1), section: path.section)
|
||||||
} else if splitFavorites && path.section == 0 {
|
} else if path.section + 1 < sections.count {
|
||||||
target = IndexPath(item: min(column, results.count - favoriteCount - 1), section: 1)
|
target = IndexPath(item: min(column, sections[path.section + 1].range.count - 1), section: path.section + 1)
|
||||||
}
|
}
|
||||||
moveSelection(by: index(for: target) - selected)
|
moveSelection(by: index(for: target) - selected)
|
||||||
}
|
}
|
||||||
@@ -220,7 +242,9 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
|
|||||||
copyButton.isEnabled = !results.isEmpty
|
copyButton.isEnabled = !results.isEmpty
|
||||||
actionsButton.isEnabled = !results.isEmpty
|
actionsButton.isEnabled = !results.isEmpty
|
||||||
status.stringValue = results.isEmpty ? "0 emoji" : "\(results[selected].name) · \(results.count) emoji"
|
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) }
|
@objc func copySelection() { copySelection(pasting: false) }
|
||||||
@@ -241,14 +265,28 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
|
|||||||
if pasting { pasteRequested?() } else { copied?() }
|
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 {
|
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 {
|
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 {
|
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
|
@MainActor
|
||||||
private final class EmojiItem: NSCollectionViewItem {
|
private final class EmojiItem: NSCollectionViewItem {
|
||||||
let button = EmojiButton()
|
let button = EmojiButton()
|
||||||
var clicked: (() -> Void)?
|
var clicked: (() -> Void)?
|
||||||
|
|
||||||
override func loadView() {
|
override func loadView() {
|
||||||
button.font = .systemFont(ofSize: 32)
|
button.font = .systemFont(ofSize: 28)
|
||||||
button.isBordered = false
|
button.isBordered = false
|
||||||
button.target = self
|
button.target = self
|
||||||
button.action = #selector(copyEmoji)
|
button.action = #selector(copyEmoji)
|
||||||
button.wantsLayer = true
|
button.wantsLayer = true
|
||||||
button.layer?.cornerRadius = 10
|
button.layer?.cornerRadius = 10
|
||||||
button.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.22).cgColor
|
|
||||||
view = button
|
view = button
|
||||||
}
|
}
|
||||||
|
|
||||||
func highlight(_ selected: Bool) {
|
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.setAccessibilitySelected(selected)
|
||||||
button.selected = selected
|
button.selected = selected
|
||||||
}
|
}
|
||||||
@@ -314,5 +378,7 @@ private final class EmojiButton: NSButton {
|
|||||||
|
|
||||||
private func updateBorder() {
|
private func updateBorder() {
|
||||||
layer?.borderWidth = selected || hovered ? 0.5 : 0
|
layer?.borderWidth = selected || hovered ? 0.5 : 0
|
||||||
|
layer?.borderColor = (selected ? SunsetPalette.accent.withAlphaComponent(0.8)
|
||||||
|
: SunsetPalette.secondaryText.withAlphaComponent(0.35)).cgColor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import Testing
|
|||||||
let grid = try #require(scroll.documentView as? NSCollectionView)
|
let grid = try #require(scroll.documentView as? NSCollectionView)
|
||||||
let layout = try #require(grid.collectionViewLayout)
|
let layout = try #require(grid.collectionViewLayout)
|
||||||
let first = try #require(layout.layoutAttributesForItem(at: IndexPath(item: 0, section: 0)))
|
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 }
|
guard let frame = layout.layoutAttributesForItem(at: IndexPath(item: index, section: 0))?.frame else { return false }
|
||||||
return frame.minY > first.frame.minY
|
return frame.minY > first.frame.minY
|
||||||
})
|
})
|
||||||
@@ -27,3 +27,40 @@ import Testing
|
|||||||
#expect(pasteboard.string(forType: .string) == EmojiCatalog.all[nextRow].value)
|
#expect(pasteboard.string(forType: .string) == EmojiCatalog.all[nextRow].value)
|
||||||
print("Emoji grid: \(nextRow) columns at 750 points; down navigation follows the rendered row.")
|
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("🚀")
|
||||||
|
}
|
||||||
|
|||||||
@@ -71,8 +71,13 @@ import Testing
|
|||||||
func expectVisible() throws {
|
func expectVisible() throws {
|
||||||
picker.copySelection()
|
picker.copySelection()
|
||||||
let index = try #require(ordered.firstIndex { $0.value == pasteboard.string(forType: .string) })
|
let index = try #require(ordered.firstIndex { $0.value == pasteboard.string(forType: .string) })
|
||||||
let path = favorites && index >= favored.count
|
var item = index
|
||||||
? IndexPath(item: index - favored.count, section: 1) : IndexPath(item: index, section: 0)
|
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)
|
let frame = try #require(grid.collectionViewLayout?.layoutAttributesForItem(at: path)?.frame)
|
||||||
try #require(scroll.documentVisibleRect.contains(frame))
|
try #require(scroll.documentVisibleRect.contains(frame))
|
||||||
}
|
}
|
||||||
@@ -239,7 +244,7 @@ import Testing
|
|||||||
#expect(table.numberOfRows == 1)
|
#expect(table.numberOfRows == 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test @MainActor func emojiFavoritesPersistAndNavigationCrossesTheSpacingBreak() throws {
|
@Test @MainActor func emojiFavoritesPersistAndNavigationCrossesSections() throws {
|
||||||
_ = NSApplication.shared
|
_ = NSApplication.shared
|
||||||
let suite = "ConjureEmojiFavorites.\(UUID().uuidString)"
|
let suite = "ConjureEmojiFavorites.\(UUID().uuidString)"
|
||||||
let defaults = UserDefaults(suiteName: suite)!
|
let defaults = UserDefaults(suiteName: suite)!
|
||||||
@@ -254,9 +259,6 @@ import Testing
|
|||||||
query = ""
|
query = ""
|
||||||
picker.inputChanged()
|
picker.inputChanged()
|
||||||
picker.view.layoutSubtreeIfNeeded()
|
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"))
|
#expect(picker.handleControlKey("j"))
|
||||||
picker.copySelection()
|
picker.copySelection()
|
||||||
#expect(pasteboard.string(forType: .string) == EmojiCatalog.all[0].value)
|
#expect(pasteboard.string(forType: .string) == EmojiCatalog.all[0].value)
|
||||||
@@ -271,7 +273,6 @@ import Testing
|
|||||||
#expect(picker.handleControlKey("k"))
|
#expect(picker.handleControlKey("k"))
|
||||||
picker.toggleFavorite()
|
picker.toggleFavorite()
|
||||||
#expect(defaults.stringArray(forKey: "favoriteEmoji") == [])
|
#expect(defaults.stringArray(forKey: "favoriteEmoji") == [])
|
||||||
#expect(grid.numberOfSections == 1)
|
|
||||||
query = "👩💻"
|
query = "👩💻"
|
||||||
picker.inputChanged()
|
picker.inputChanged()
|
||||||
picker.toggleFavorite()
|
picker.toggleFavorite()
|
||||||
|
|||||||
Reference in New Issue
Block a user