diff --git a/README.md b/README.md index 2fa85f9..e29ee86 100644 --- a/README.md +++ b/README.md @@ -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. -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. diff --git a/Sources/Conjure/EmojiPickerController.swift b/Sources/Conjure/EmojiPickerController.swift index c3b2919..2155605 100644 --- a/Sources/Conjure/EmojiPickerController.swift +++ b/Sources/Conjure/EmojiPickerController.swift @@ -284,7 +284,7 @@ final class EmojiPickerController: NSViewController, NSCollectionViewDataSource, @MainActor private final class EmojiItem: NSCollectionViewItem { - let button = NSButton() + let button = EmojiButton() var clicked: (() -> Void)? override func loadView() { @@ -294,7 +294,6 @@ private final class EmojiItem: NSCollectionViewItem { button.action = #selector(copyEmoji) button.wantsLayer = true button.layer?.cornerRadius = 10 - button.layer?.borderWidth = 0.5 button.layer?.borderColor = SunsetPalette.secondaryText.withAlphaComponent(0.22).cgColor view = button } @@ -302,7 +301,33 @@ private final class EmojiItem: NSCollectionViewItem { func highlight(_ selected: Bool) { button.layer?.backgroundColor = selected ? SunsetPalette.accent.withAlphaComponent(0.22).cgColor : nil button.setAccessibilitySelected(selected) + button.selected = selected } @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 + } +} diff --git a/Tests/ConjureKitTests/EmojiBorderTests.swift b/Tests/ConjureKitTests/EmojiBorderTests.swift new file mode 100644 index 0000000..6e76d53 --- /dev/null +++ b/Tests/ConjureKitTests/EmojiBorderTests.swift @@ -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) +}