style: limit emoji borders to selected and hovered tiles

This commit is contained in:
2026-10-17 01:29:38 +02:00
parent 6085cbd217
commit 1980c9dbc1
3 changed files with 71 additions and 3 deletions
+1 -1
View File
@@ -106,7 +106,7 @@ Rates are shared across queries and refreshed on the next currency query after t
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 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.
Each emoji tile has the same subtle rounded border used elsewhere in Conjure; the selected tile retains its accent fill. Emoji tiles show the same subtle rounded border used elsewhere in Conjure only when selected or hovered; the selected tile retains its accent fill.
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.
+27 -2
View File
@@ -284,7 +284,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource,
@MainActor @MainActor
private final class EmojiItem: NSCollectionViewItem { private final class EmojiItem: NSCollectionViewItem {
let button = NSButton() let button = EmojiButton()
var clicked: (() -> Void)? var clicked: (() -> Void)?
override func loadView() { override func loadView() {
@@ -294,7 +294,6 @@ private final class EmojiItem: NSCollectionViewItem {
button.action = #selector(copyEmoji) button.action = #selector(copyEmoji)
button.wantsLayer = true button.wantsLayer = true
button.layer?.cornerRadius = 10 button.layer?.cornerRadius = 10
button.layer?.borderWidth = 0.5
button.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.22).cgColor button.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.22).cgColor
view = button view = button
} }
@@ -302,7 +301,33 @@ private final class EmojiItem: NSCollectionViewItem {
func highlight(_ selected: Bool) { func highlight(_ selected: Bool) {
button.layer?.backgroundColor = selected ? SunsetPalette.accent.withAlphaComponent(0.22).cgColor : nil button.layer?.backgroundColor = selected ? SunsetPalette.accent.withAlphaComponent(0.22).cgColor : nil
button.setAccessibilitySelected(selected) button.setAccessibilitySelected(selected)
button.selected = selected
} }
@objc private func copyEmoji() { clicked?() } @objc private func copyEmoji() { clicked?() }
} }
@MainActor
private final class EmojiButton: NSButton {
var selected = false { didSet { updateBorder() } }
private var hovered = false { didSet { updateBorder() } }
private var hoverTracking: NSTrackingArea?
override func updateTrackingAreas() {
super.updateTrackingAreas()
if let hoverTracking { removeTrackingArea(hoverTracking) }
let tracking = NSTrackingArea(rect: .zero,
options: [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect],
owner: self, userInfo: nil)
addTrackingArea(tracking)
hoverTracking = tracking
hovered = window.map { visibleRect.contains(convert($0.mouseLocationOutsideOfEventStream, from: nil)) } ?? false
}
override func mouseEntered(with event: NSEvent) { hovered = true }
override func mouseExited(with event: NSEvent) { hovered = false }
private func updateBorder() {
layer?.borderWidth = selected || hovered ? 0.5 : 0
}
}
@@ -0,0 +1,43 @@
import AppKit
import Testing
@testable import Conjure
@Test @MainActor func emojiBordersFollowSelectionAndHover() throws {
_ = NSApplication.shared
let suite = "EmojiBorders.\(UUID().uuidString)"
let defaults = UserDefaults(suiteName: suite)!
defer { defaults.removePersistentDomain(forName: suite) }
let picker = EmojiPickerController(defaults: defaults)
picker.input = { "face" }
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)
let first = try #require(grid.item(at: IndexPath(item: 0, section: 0))?.view as? NSButton)
let second = try #require(grid.item(at: IndexPath(item: 1, section: 0))?.view as? NSButton)
let enter = try #require(NSEvent.enterExitEvent(with: .mouseEntered, location: .zero,
modifierFlags: [], timestamp: 0, windowNumber: window.windowNumber,
context: nil, eventNumber: 0, trackingNumber: 0, userData: nil))
let exit = try #require(NSEvent.enterExitEvent(with: .mouseExited, location: .zero,
modifierFlags: [], timestamp: 0, windowNumber: window.windowNumber,
context: nil, eventNumber: 0, trackingNumber: 0, userData: nil))
first.mouseExited(with: exit)
second.mouseExited(with: exit)
#expect(first.layer?.borderWidth == 0.5)
#expect(second.layer?.borderWidth == 0)
second.mouseEntered(with: enter)
#expect(second.layer?.borderWidth == 0.5)
picker.moveSelection(by: 1)
#expect(first.layer?.borderWidth == 0)
second.mouseExited(with: exit)
#expect(second.layer?.borderWidth == 0.5)
picker.moveSelection(by: -1)
#expect(second.layer?.borderWidth == 0)
first.mouseEntered(with: enter)
picker.moveSelection(by: 1)
#expect(first.layer?.borderWidth == 0.5)
first.mouseExited(with: exit)
#expect(first.layer?.borderWidth == 0)
}