Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 25 additions & 3 deletions Clipy/Sources/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class AppDelegate: NSObject, NSMenuItemValidation {
// MARK: - Properties
private(set) var updaterController: SPUStandardUpdaterController?
private let screenshotObserver = ScreenShotObserver()
private var screenshotObserverThread: Thread?
private let disposeBag = DisposeBag()

// MARK: - Init
Expand Down Expand Up @@ -140,6 +141,7 @@ class AppDelegate: NSObject, NSMenuItemValidation {
}

func terminateApplication() {
screenshotObserverThread?.cancel()
NSApplication.shared.terminate(nil)
}

Expand Down Expand Up @@ -254,17 +256,37 @@ private extension AppDelegate {
.filter { $0 }
.take(1)
.subscribe(onNext: { [weak self] _ in
self?.screenshotObserver.start()
self?.startScreenshotObserverIfNeeded()
})
.disposed(by: disposeBag)
}

func startScreenshotObserverIfNeeded() {
guard screenshotObserverThread == nil else { return }
let thread = Thread { [weak self] in
guard let self else { return }
autoreleasepool {
self.screenshotObserver.start()
while !Thread.current.isCancelled {
RunLoop.current.run(mode: .default, before: Date(timeIntervalSinceNow: 0.5))
}
self.screenshotObserver.stop()
}
}
thread.name = "com.uniplanck.BoardMan.ScreenShotObserver"
thread.qualityOfService = .utility
screenshotObserverThread = thread
thread.start()
}
}

// MARK: - ScreenShotObserver Delegate
extension AppDelegate: ScreenShotObserverDelegate {
func screenShotObserver(_ observer: ScreenShotObserver, addedItem item: NSMetadataItem) {
guard let path = item.value(forAttribute: NSMetadataItemPathKey) as? String else { return }
guard let image = NSImage(contentsOfFile: path) else { return }
AppEnvironment.current.clipService.create(with: image)
DispatchQueue.main.async {
guard let image = NSImage(contentsOfFile: path) else { return }
AppEnvironment.current.clipService.create(with: image)
}
}
}
1 change: 1 addition & 0 deletions Clipy/Sources/Constants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ struct Constants {
static let historyKeyCombo = "kCPYHotKeyHistoryKeyCombo"
static let snippetKeyCombo = "kCPYHotKeySnippetKeyCombo"
static let migrateNewKeyCombo = "kCPYMigrateNewKeyCombo"
static let migrateOpenBoardManCommandOptionV = "kCPYMigrateOpenBoardManCommandOptionV"
static let folderKeyCombos = "kCPYFolderKeyCombos"
static let clearHistoryKeyCombo = "kCPYClearHistoryKeyCombo"
}
Expand Down
104 changes: 86 additions & 18 deletions Clipy/Sources/Managers/MenuManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ final class MenuManager: NSObject {
fileprivate var currentStatusType: StatusType?
fileprivate var boardManPanel: BoardManPanel?
fileprivate var previousFrontmostApplication: NSRunningApplication?
fileprivate var panelItemsNeedRefresh = true
// Icon Cache
fileprivate let folderIcon = NSImage(resource: .iconFolder)
fileprivate let snippetIcon = NSImage(resource: .iconText)
Expand Down Expand Up @@ -104,17 +105,14 @@ extension MenuManager {
}
}
boardManPanel?.onRefreshRequested = { [weak self] in
guard let strongSelf = self, let panel = strongSelf.boardManPanel else { return }
panel.reloadHistoryItems(strongSelf.boardManPanelItems())
guard let self, let panel = self.boardManPanel else { return }
self.reloadBoardManPanelItems(panel)
}
}
if let panel = boardManPanel {
// V4B-13: position and show first. Heavy Realm/defaults reload happens after first paint.
let panelSize = NSSize(width: BoardManPanel.preferredPanelWidth(), height: BoardManPanel.preferredPanelHeight())
let finalFrame = BoardManPanel.cursorRelativeFrame(size: panelSize, anchorPoint: anchorPoint)
if panel.presentationItemScope == .complete {
panel.reloadHistoryItems(boardManPanelItems())
}
panel.prepareForFirstVisibleOrder(frame: finalFrame)
NSApp.activate(ignoringOtherApps: true)
panel.makeKeyAndOrderFront(nil)
Expand All @@ -123,10 +121,8 @@ extension MenuManager {

DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(80)) { [weak self, weak panel] in
guard let self, let panel else { return }
let items = panel.presentationItemScope == .complete
? self.boardManPanelItems()
: self.boardManInitialPanelItems()
panel.reloadHistoryItems(items)
guard self.panelItemsNeedRefresh || !panel.hasLoadedPanelItems else { return }
self.reloadBoardManPanelItems(panel)
panel.focusTableForKeyboard()
}
}
Expand All @@ -141,7 +137,9 @@ extension MenuManager {
showBoardManPanel()
guard let panel = boardManPanel else { return }
panel.openSnippetsManagerMode(categoryIdentifier: folderIdentifier)
panel.reloadHistoryItems(boardManPanelItems())
if !panel.hasLoadedSnippetItems {
panelItemsNeedRefresh = true
}
panel.focusTableForKeyboard()
}

Expand Down Expand Up @@ -225,6 +223,14 @@ extension MenuManager {
return boardManHistoryItems()
}

fileprivate func reloadBoardManPanelItems(_ panel: BoardManPanel) {
let items = panel.presentationItemScope == .complete
? boardManPanelItems()
: boardManInitialPanelItems()
panel.reloadHistoryItems(items)
panelItemsNeedRefresh = false
}

fileprivate func boardManHistoryItems() -> [BoardManHistoryItem] {
let startedAt = CFAbsoluteTimeGetCurrent()
let maxHistory = max(1, AppEnvironment.current.defaults.integer(forKey: Constants.UserDefaults.maxHistorySize))
Expand All @@ -236,14 +242,15 @@ extension MenuManager {
let showUsageCount = defaults.object(forKey: Constants.UserDefaults.boardManShowUsageCount) as? Bool ?? true

let pinStore = PinnedSnippetStore.shared
let pasteCounts = PasteCountStore.shared.countsSnapshot()
let panelHistoryLimit = min(maxHistory, 120) // Keep panel launch fast; full history remains in Realm.
let items = Array(clipResults.prefix(panelHistoryLimit)).enumerated().map { index, clip in
let rawTitle = clip.title.trimmingCharacters(in: .whitespacesAndNewlines)
let isImageClip = BoardManPanel.isImageClip(clip)
let title = rawTitle.isEmpty ? (isImageClip ? BoardManPanel.imageClipTitle(for: clip) : "(empty clipboard item)") : rawTitle
let firstLine = title.components(separatedBy: .newlines).first ?? title
let clipped = firstLine.count > 120 ? String(firstLine.prefix(117)) + "..." : firstLine
let pasteCount = PasteCountStore.shared.count(for: clip)
let pasteCount = PasteCountStore.shared.count(for: clip, in: pasteCounts)
let isPinned = pinStore.isPinned(clip.dataHash)
var parts: [String] = []
if showRowNumbers {
Expand Down Expand Up @@ -457,6 +464,7 @@ private extension MenuManager {
}

func createClipMenu() {
panelItemsNeedRefresh = true
createBoardManLiteMenu()
}

Expand Down Expand Up @@ -1447,6 +1455,47 @@ private final class BoardManHistoryRowView: NSTableRowView {
}
}

final class BoardManSettingsCategoryButton: NSButton {
private var hoverTrackingArea: NSTrackingArea?
private(set) var isHovering = false
var hoverDidChange: (() -> Void)?

override func updateTrackingAreas() {
if let hoverTrackingArea {
removeTrackingArea(hoverTrackingArea)
}
let trackingArea = NSTrackingArea(
rect: bounds,
options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect],
owner: self,
userInfo: nil
)
addTrackingArea(trackingArea)
hoverTrackingArea = trackingArea
super.updateTrackingAreas()
}

override func mouseEntered(with event: NSEvent) {
setHovering(true)
super.mouseEntered(with: event)
}

override func mouseExited(with event: NSEvent) {
setHovering(false)
super.mouseExited(with: event)
}

func setHoveringForTesting(_ value: Bool) {
setHovering(value)
}

private func setHovering(_ value: Bool) {
guard isHovering != value else { return }
isHovering = value
hoverDidChange?()
}
}

final class BoardManHeaderSegmentedControl: NSSegmentedControl {
private var hoverTrackingArea: NSTrackingArea?
private(set) var hoveredSegment = -1
Expand Down Expand Up @@ -1804,6 +1853,14 @@ class BoardManPanel: NSPanel {
return historyItems.count
}

var hasLoadedPanelItems: Bool {
return !allItems.isEmpty
}

var hasLoadedSnippetItems: Bool {
return allItems.contains { $0.source == .snippet }
}

var presentationItemScope: BoardManPresentationItemScope {
return activeTab == .snippets ? .complete : .historyOnly
}
Expand Down Expand Up @@ -2232,7 +2289,7 @@ class BoardManPanel: NSPanel {
settingsSidebarView = sidebar

settingsCategoryButtons = BoardManInlineSettingsCategory.allCases.map { category in
let button = NSButton(title: category.title, target: self, action: #selector(settingsCategoryButtonPressed(_:)))
let button = BoardManSettingsCategoryButton(title: category.title, target: self, action: #selector(settingsCategoryButtonPressed(_:)))
button.tag = category.rawValue
button.isBordered = false
button.alignment = .left
Expand All @@ -2246,6 +2303,9 @@ class BoardManPanel: NSPanel {
named: category.symbolName,
title: category.title
)
button.hoverDidChange = { [weak self] in
self?.updateSettingsSidebarSelection()
}
sidebar.addSubview(button)
return button
}
Expand Down Expand Up @@ -3111,7 +3171,7 @@ class BoardManPanel: NSPanel {
let rightWidth = max(0, width - tabsWidth - headerGap)
let searchWidth = max(isCompact ? 130 : 170,
rightWidth - snippetButtonsWidth - (showsSnippetButtons ? headerGap : 0))
let searchFrame = NSIntegralRect(NSRect(x: rightX, y: headerY - 2, width: searchWidth, height: 40))
let searchFrame = NSIntegralRect(NSRect(x: rightX, y: headerY, width: searchWidth, height: 36))
searchField?.frame = searchFrame
snippetAddButton?.isHidden = !showsSnippetButtons
snippetEditButton?.isHidden = !showsSnippetButtons
Expand Down Expand Up @@ -3264,12 +3324,20 @@ class BoardManPanel: NSPanel {
settingsPageDescriptionLabel?.stringValue = activeSettingsCategory.detail
for button in settingsCategoryButtons {
let isSelected = button.tag == activeSettingsCategory.rawValue
let isHovering = (button as? BoardManSettingsCategoryButton)?.isHovering == true
button.state = isSelected ? .on : .off
button.layer?.backgroundColor = isSelected
? themeAccentColor.withAlphaComponent(isThemeLightenEnabled ? 0.10 : 0.16).cgColor
: NSColor.clear.cgColor
button.layer?.borderWidth = isSelected ? 1 : 0
button.layer?.borderColor = themeAccentColor.withAlphaComponent(0.26).cgColor
if isSelected {
button.layer?.backgroundColor = themeAccentColor
.withAlphaComponent(isThemeLightenEnabled ? 0.12 : 0.18).cgColor
} else if isHovering {
button.layer?.backgroundColor = themeAccentColor
.withAlphaComponent(isThemeLightenEnabled ? 0.07 : 0.11).cgColor
} else {
button.layer?.backgroundColor = NSColor.clear.cgColor
}
button.layer?.borderWidth = (isSelected || isHovering) ? 1 : 0
button.layer?.borderColor = themeAccentColor
.withAlphaComponent(isSelected ? 0.32 : 0.20).cgColor
}
}

Expand Down
11 changes: 10 additions & 1 deletion Clipy/Sources/Services/AccessibilityService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,13 @@ extension AccessibilityService {
NSApp.activate(ignoringOtherApps: true)

if alert.runModal() == NSApplication.ModalResponse.alertFirstButtonReturn {
guard !openAccessibilitySettingWindow() else { return }
if !isAccessibilityEnabled(isPrompt: false) {
guard !openAccessibilitySettingWindow() else { return }
isAccessibilityEnabled(isPrompt: true)
return
}
if !isListenEventAccessEnabled() {
_ = openInputMonitoringSettingWindow()
}
}
}
Expand All @@ -75,4 +79,9 @@ extension AccessibilityService {
guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility") else { return false }
return NSWorkspace.shared.open(url)
}

func openInputMonitoringSettingWindow() -> Bool {
guard let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_ListenEvent") else { return false }
return NSWorkspace.shared.open(url)
}
}
33 changes: 28 additions & 5 deletions Clipy/Sources/Services/HotKeyService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,19 +12,20 @@

import Foundation
import Cocoa
import Carbon
import Magnet
import RealmSwift

final class HotKeyService: NSObject {

// MARK: - Properties
static var defaultKeyCombos: [String: Any] = {
// MainMenu: ⌘ + Shift + V
// MainMenu: ⌘ + Option + V
// HistoryMenu: ⌘ + Control + V
// SnipeetMenu: ⌘ + Shift B
return [Constants.Menu.clip: ["keyCode": 9, "modifiers": 768],
Constants.Menu.history: ["keyCode": 9, "modifiers": 4352],
Constants.Menu.snippet: ["keyCode": 11, "modifiers": 768]]
// SnippetMenu: ⌘ + Shift + B
return [Constants.Menu.clip: ["keyCode": 9, "modifiers": Int(cmdKey) | Int(optionKey)],
Constants.Menu.history: ["keyCode": 9, "modifiers": Int(cmdKey) | Int(controlKey)],
Constants.Menu.snippet: ["keyCode": 11, "modifiers": Int(cmdKey) | Int(shiftKey)]]
}()

fileprivate(set) var mainKeyCombo: KeyCombo?
Expand Down Expand Up @@ -63,6 +64,7 @@ extension HotKeyService {
AppEnvironment.current.defaults.set(true, forKey: Constants.HotKey.migrateNewKeyCombo)
AppEnvironment.current.defaults.synchronize()
}
migrateLegacyMainShortcutIfNeeded()
// Snippet hotkey
setupSnippetHotKeys()

Expand Down Expand Up @@ -159,6 +161,27 @@ private extension HotKeyService {
guard let keyCode = combos["keyCode"] as? Int, let modifiers = combos["modifiers"] as? Int else { return nil }
return (keyCode, modifiers)
}

func migrateLegacyMainShortcutIfNeeded() {
let defaults = AppEnvironment.current.defaults
guard !defaults.bool(forKey: Constants.HotKey.migrateOpenBoardManCommandOptionV) else { return }
defer {
defaults.set(true, forKey: Constants.HotKey.migrateOpenBoardManCommandOptionV)
defaults.synchronize()
}

guard let current = savedKeyCombo(forKey: Constants.HotKey.mainKeyCombo),
current.QWERTYKeyCode == 9,
current.modifiers == (Int(cmdKey) | Int(shiftKey)),
current.doubledModifiers == false,
let replacement = KeyCombo(
QWERTYKeyCode: 9,
carbonModifiers: Int(cmdKey) | Int(optionKey)
) else {
return
}
defaults.set(replacement.archive(), forKey: Constants.HotKey.mainKeyCombo)
}
}

// MARK: - Snippet HotKey
Expand Down
Loading
Loading