WIP: add Dock visibility setting
This commit is contained in:
@@ -1,12 +1,12 @@
|
|||||||
# ClipBored
|
# ClipBored
|
||||||
|
|
||||||
ClipBored is a small native macOS clipboard manager. It runs without a Dock icon, captures local clipboard history, and opens a keyboard-first bottom panel for search, sorting, copy, paste, pinning, and deletion.
|
ClipBored is a small native macOS clipboard manager. It captures local clipboard history and opens a keyboard-first bottom panel for search, sorting, copy, paste, pinning, and deletion. It runs as a dockless menu-bar utility by default, with an optional Dock icon mode.
|
||||||
|
|
||||||
The project is intentionally dependency-light: Swift Package Manager, AppKit, Carbon hotkeys, SQLite, and system frameworks only.
|
The project is intentionally dependency-light: Swift Package Manager, AppKit, Carbon hotkeys, SQLite, and system frameworks only.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Dockless menu-bar utility (`LSUIElement=true`)
|
- Dockless menu-bar utility by default (`LSUIElement=true`), with a Settings toggle for normal Dock presence
|
||||||
- Right-click menu-bar status menu with capture state, history count, settings, pause/resume, and quit
|
- Right-click menu-bar status menu with capture state, history count, settings, pause/resume, and quit
|
||||||
- Global shortcuts:
|
- Global shortcuts:
|
||||||
- `Command + Option + V` toggles the clipboard panel
|
- `Command + Option + V` toggles the clipboard panel
|
||||||
@@ -18,7 +18,7 @@ The project is intentionally dependency-light: Swift Package Manager, AppKit, Ca
|
|||||||
- Custom named collections for organizing clips from the card context menu
|
- Custom named collections for organizing clips from the card context menu
|
||||||
- Copy and paste actions with Accessibility permission fallback
|
- Copy and paste actions with Accessibility permission fallback
|
||||||
- Image thumbnail cache with byte and file-count pruning
|
- Image thumbnail cache with byte and file-count pruning
|
||||||
- Configurable history length, cache limit, polling profile, ignored apps, content kinds, launch-at-login, and clear-on-quit behavior
|
- Configurable history length, cache limit, polling profile, ignored apps, content kinds, launch-at-login, Dock/menu-bar presence, and clear-on-quit behavior
|
||||||
- Local-only storage, with optional sensitive-content exclusion for common secrets
|
- Local-only storage, with optional sensitive-content exclusion for common secrets
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|||||||
@@ -1,6 +1,17 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
|
||||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
|
enum PresentationSurface: Equatable {
|
||||||
|
case menuBar
|
||||||
|
case dock
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PresentationPlan: Equatable {
|
||||||
|
let showMenuBarIcon: Bool
|
||||||
|
let showDockIcon: Bool
|
||||||
|
let activationPolicy: NSApplication.ActivationPolicy
|
||||||
|
}
|
||||||
|
|
||||||
struct StatusMenuPresentation: Equatable {
|
struct StatusMenuPresentation: Equatable {
|
||||||
let summary: String
|
let summary: String
|
||||||
let detail: String?
|
let detail: String?
|
||||||
@@ -61,6 +72,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
settingsShortcut: settings.settingsShortcut
|
settingsShortcut: settings.settingsShortcut
|
||||||
)
|
)
|
||||||
bindSettings()
|
bindSettings()
|
||||||
|
applyPresentation(changedSurface: nil)
|
||||||
monitor.setPaused(settings.pauseCapture)
|
monitor.setPaused(settings.pauseCapture)
|
||||||
monitor.start()
|
monitor.start()
|
||||||
shortcutManager.start()
|
shortcutManager.start()
|
||||||
@@ -415,7 +427,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
case .launchAtLogin:
|
case .launchAtLogin:
|
||||||
applyLaunchAtLoginSetting(settings.launchAtLogin)
|
applyLaunchAtLoginSetting(settings.launchAtLogin)
|
||||||
case .showMenuBarIcon:
|
case .showMenuBarIcon:
|
||||||
refreshStatusItem()
|
applyPresentation(changedSurface: .menuBar)
|
||||||
|
case .showDockIcon:
|
||||||
|
applyPresentation(changedSurface: .dock)
|
||||||
case .pauseCapture:
|
case .pauseCapture:
|
||||||
monitor.setPaused(settings.pauseCapture)
|
monitor.setPaused(settings.pauseCapture)
|
||||||
if settings.showMenuBarIcon {
|
if settings.showMenuBarIcon {
|
||||||
@@ -430,6 +444,48 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func presentationPlan(
|
||||||
|
showMenuBarIcon: Bool,
|
||||||
|
showDockIcon: Bool,
|
||||||
|
changedSurface: PresentationSurface?
|
||||||
|
) -> PresentationPlan {
|
||||||
|
var plannedMenuBarIcon = showMenuBarIcon
|
||||||
|
var plannedDockIcon = showDockIcon
|
||||||
|
|
||||||
|
if !plannedMenuBarIcon && !plannedDockIcon {
|
||||||
|
if changedSurface == .menuBar {
|
||||||
|
plannedDockIcon = true
|
||||||
|
} else {
|
||||||
|
plannedMenuBarIcon = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return PresentationPlan(
|
||||||
|
showMenuBarIcon: plannedMenuBarIcon,
|
||||||
|
showDockIcon: plannedDockIcon,
|
||||||
|
activationPolicy: plannedDockIcon ? .regular : .accessory
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyPresentation(changedSurface: PresentationSurface?) {
|
||||||
|
let plan = Self.presentationPlan(
|
||||||
|
showMenuBarIcon: settings.showMenuBarIcon,
|
||||||
|
showDockIcon: settings.showDockIcon,
|
||||||
|
changedSurface: changedSurface
|
||||||
|
)
|
||||||
|
|
||||||
|
if settings.showMenuBarIcon != plan.showMenuBarIcon {
|
||||||
|
settings.showMenuBarIcon = plan.showMenuBarIcon
|
||||||
|
}
|
||||||
|
if settings.showDockIcon != plan.showDockIcon {
|
||||||
|
settings.showDockIcon = plan.showDockIcon
|
||||||
|
}
|
||||||
|
|
||||||
|
NSApp.setActivationPolicy(plan.activationPolicy)
|
||||||
|
configureMainMenu()
|
||||||
|
refreshStatusItem()
|
||||||
|
}
|
||||||
|
|
||||||
private func applyLaunchAtLoginSetting(_ shouldLaunch: Bool) {
|
private func applyLaunchAtLoginSetting(_ shouldLaunch: Bool) {
|
||||||
let result = lifecycleService.applyLaunchAtLogin(shouldLaunch)
|
let result = lifecycleService.applyLaunchAtLogin(shouldLaunch)
|
||||||
switch result {
|
switch result {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ final class SettingsModel {
|
|||||||
case settingsShortcut
|
case settingsShortcut
|
||||||
case launchAtLogin
|
case launchAtLogin
|
||||||
case showMenuBarIcon
|
case showMenuBarIcon
|
||||||
|
case showDockIcon
|
||||||
case pauseCapture
|
case pauseCapture
|
||||||
case pollProfile
|
case pollProfile
|
||||||
case captureStatus
|
case captureStatus
|
||||||
@@ -23,6 +24,7 @@ final class SettingsModel {
|
|||||||
static let pruneDuplicates = "pruneDuplicates"
|
static let pruneDuplicates = "pruneDuplicates"
|
||||||
static let launchAtLogin = "launchAtLogin"
|
static let launchAtLogin = "launchAtLogin"
|
||||||
static let showMenuBarIcon = "showMenuBarIcon"
|
static let showMenuBarIcon = "showMenuBarIcon"
|
||||||
|
static let showDockIcon = "showDockIcon"
|
||||||
static let openShortcut = "openShortcut"
|
static let openShortcut = "openShortcut"
|
||||||
static let settingsShortcut = "settingsShortcut"
|
static let settingsShortcut = "settingsShortcut"
|
||||||
static let ignoredApps = "ignoredApps"
|
static let ignoredApps = "ignoredApps"
|
||||||
@@ -56,6 +58,9 @@ final class SettingsModel {
|
|||||||
var showMenuBarIcon: Bool {
|
var showMenuBarIcon: Bool {
|
||||||
didSet { if oldValue != showMenuBarIcon { storeAndNotify(.showMenuBarIcon) } }
|
didSet { if oldValue != showMenuBarIcon { storeAndNotify(.showMenuBarIcon) } }
|
||||||
}
|
}
|
||||||
|
var showDockIcon: Bool {
|
||||||
|
didSet { if oldValue != showDockIcon { storeAndNotify(.showDockIcon) } }
|
||||||
|
}
|
||||||
var openShortcut: ShortcutBinding {
|
var openShortcut: ShortcutBinding {
|
||||||
didSet { if oldValue != openShortcut { storeAndNotify(.openShortcut) } }
|
didSet { if oldValue != openShortcut { storeAndNotify(.openShortcut) } }
|
||||||
}
|
}
|
||||||
@@ -107,6 +112,7 @@ final class SettingsModel {
|
|||||||
pruneDuplicates = defaults.object(forKey: Keys.pruneDuplicates) as? Bool ?? true
|
pruneDuplicates = defaults.object(forKey: Keys.pruneDuplicates) as? Bool ?? true
|
||||||
launchAtLogin = defaults.object(forKey: Keys.launchAtLogin) as? Bool ?? false
|
launchAtLogin = defaults.object(forKey: Keys.launchAtLogin) as? Bool ?? false
|
||||||
showMenuBarIcon = defaults.object(forKey: Keys.showMenuBarIcon) as? Bool ?? true
|
showMenuBarIcon = defaults.object(forKey: Keys.showMenuBarIcon) as? Bool ?? true
|
||||||
|
showDockIcon = defaults.object(forKey: Keys.showDockIcon) as? Bool ?? false
|
||||||
openShortcut = Self.readShortcut(from: defaults.string(forKey: Keys.openShortcut)) ?? AppConfiguration.defaultOpenShortcut
|
openShortcut = Self.readShortcut(from: defaults.string(forKey: Keys.openShortcut)) ?? AppConfiguration.defaultOpenShortcut
|
||||||
settingsShortcut = Self.readShortcut(from: defaults.string(forKey: Keys.settingsShortcut)) ?? AppConfiguration.defaultSettingsShortcut
|
settingsShortcut = Self.readShortcut(from: defaults.string(forKey: Keys.settingsShortcut)) ?? AppConfiguration.defaultSettingsShortcut
|
||||||
ignoredApps = defaults.stringArray(forKey: Keys.ignoredApps) ?? AppConfiguration.defaultIgnoredApps
|
ignoredApps = defaults.stringArray(forKey: Keys.ignoredApps) ?? AppConfiguration.defaultIgnoredApps
|
||||||
@@ -135,6 +141,7 @@ final class SettingsModel {
|
|||||||
defaults.set(pruneDuplicates, forKey: Keys.pruneDuplicates)
|
defaults.set(pruneDuplicates, forKey: Keys.pruneDuplicates)
|
||||||
defaults.set(launchAtLogin, forKey: Keys.launchAtLogin)
|
defaults.set(launchAtLogin, forKey: Keys.launchAtLogin)
|
||||||
defaults.set(showMenuBarIcon, forKey: Keys.showMenuBarIcon)
|
defaults.set(showMenuBarIcon, forKey: Keys.showMenuBarIcon)
|
||||||
|
defaults.set(showDockIcon, forKey: Keys.showDockIcon)
|
||||||
defaults.set(openShortcut.encoded(), forKey: Keys.openShortcut)
|
defaults.set(openShortcut.encoded(), forKey: Keys.openShortcut)
|
||||||
defaults.set(settingsShortcut.encoded(), forKey: Keys.settingsShortcut)
|
defaults.set(settingsShortcut.encoded(), forKey: Keys.settingsShortcut)
|
||||||
defaults.set(ignoredApps, forKey: Keys.ignoredApps)
|
defaults.set(ignoredApps, forKey: Keys.ignoredApps)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD
|
|||||||
private let defaultSortPopup = NSPopUpButton()
|
private let defaultSortPopup = NSPopUpButton()
|
||||||
private let launchAtLoginButton = NSButton()
|
private let launchAtLoginButton = NSButton()
|
||||||
private let showMenuBarIconButton = NSButton()
|
private let showMenuBarIconButton = NSButton()
|
||||||
|
private let showDockIconButton = NSButton()
|
||||||
private let launchStatusLabel = NSTextField(labelWithString: "")
|
private let launchStatusLabel = NSTextField(labelWithString: "")
|
||||||
|
|
||||||
private var openShortcutControls: ShortcutControlSet?
|
private var openShortcutControls: ShortcutControlSet?
|
||||||
@@ -124,6 +125,7 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD
|
|||||||
}
|
}
|
||||||
configureCheckbox(launchAtLoginButton, title: "Launch at login", action: #selector(launchAtLoginChanged))
|
configureCheckbox(launchAtLoginButton, title: "Launch at login", action: #selector(launchAtLoginChanged))
|
||||||
configureCheckbox(showMenuBarIconButton, title: "Show ClipBored in the menu bar", action: #selector(showMenuBarIconChanged))
|
configureCheckbox(showMenuBarIconButton, title: "Show ClipBored in the menu bar", action: #selector(showMenuBarIconChanged))
|
||||||
|
configureCheckbox(showDockIconButton, title: "Show ClipBored in the Dock", action: #selector(showDockIconChanged))
|
||||||
configureStatusLabel(launchStatusLabel)
|
configureStatusLabel(launchStatusLabel)
|
||||||
|
|
||||||
return page([
|
return page([
|
||||||
@@ -138,6 +140,7 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD
|
|||||||
section("Lifecycle", [
|
section("Lifecycle", [
|
||||||
launchAtLoginButton,
|
launchAtLoginButton,
|
||||||
showMenuBarIconButton,
|
showMenuBarIconButton,
|
||||||
|
showDockIconButton,
|
||||||
launchStatusLabel
|
launchStatusLabel
|
||||||
])
|
])
|
||||||
])
|
])
|
||||||
@@ -392,6 +395,7 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD
|
|||||||
select(defaultSortPopup, rawValue: settings.defaultSortMode.rawValue)
|
select(defaultSortPopup, rawValue: settings.defaultSortMode.rawValue)
|
||||||
launchAtLoginButton.state = settings.launchAtLogin ? .on : .off
|
launchAtLoginButton.state = settings.launchAtLogin ? .on : .off
|
||||||
showMenuBarIconButton.state = settings.showMenuBarIcon ? .on : .off
|
showMenuBarIconButton.state = settings.showMenuBarIcon ? .on : .off
|
||||||
|
showDockIconButton.state = settings.showDockIcon ? .on : .off
|
||||||
launchStatusLabel.stringValue = settings.launchAtLoginErrorMessage
|
launchStatusLabel.stringValue = settings.launchAtLoginErrorMessage
|
||||||
|
|
||||||
refreshShortcutControls(openShortcutControls, binding: settings.openShortcut)
|
refreshShortcutControls(openShortcutControls, binding: settings.openShortcut)
|
||||||
@@ -468,7 +472,19 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD
|
|||||||
}
|
}
|
||||||
|
|
||||||
@objc private func showMenuBarIconChanged() {
|
@objc private func showMenuBarIconChanged() {
|
||||||
settings.showMenuBarIcon = showMenuBarIconButton.state == .on
|
let shouldShowMenuBarIcon = showMenuBarIconButton.state == .on
|
||||||
|
settings.showMenuBarIcon = shouldShowMenuBarIcon
|
||||||
|
if !shouldShowMenuBarIcon && !settings.showDockIcon {
|
||||||
|
settings.showDockIcon = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func showDockIconChanged() {
|
||||||
|
let shouldShowDockIcon = showDockIconButton.state == .on
|
||||||
|
settings.showDockIcon = shouldShowDockIcon
|
||||||
|
if !shouldShowDockIcon && !settings.showMenuBarIcon {
|
||||||
|
settings.showMenuBarIcon = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@objc private func shortcutChanged(_ sender: NSControl) {
|
@objc private func shortcutChanged(_ sender: NSControl) {
|
||||||
|
|||||||
@@ -3,6 +3,46 @@ import XCTest
|
|||||||
@testable import ClipBored
|
@testable import ClipBored
|
||||||
|
|
||||||
final class AppDelegateTests: XCTestCase {
|
final class AppDelegateTests: XCTestCase {
|
||||||
|
func testPresentationPlanMapsDockPreferenceToActivationPolicy() {
|
||||||
|
let dockless = AppDelegate.presentationPlan(
|
||||||
|
showMenuBarIcon: true,
|
||||||
|
showDockIcon: false,
|
||||||
|
changedSurface: nil
|
||||||
|
)
|
||||||
|
XCTAssertTrue(dockless.showMenuBarIcon)
|
||||||
|
XCTAssertFalse(dockless.showDockIcon)
|
||||||
|
XCTAssertEqual(dockless.activationPolicy, .accessory)
|
||||||
|
|
||||||
|
let dockVisible = AppDelegate.presentationPlan(
|
||||||
|
showMenuBarIcon: true,
|
||||||
|
showDockIcon: true,
|
||||||
|
changedSurface: nil
|
||||||
|
)
|
||||||
|
XCTAssertTrue(dockVisible.showMenuBarIcon)
|
||||||
|
XCTAssertTrue(dockVisible.showDockIcon)
|
||||||
|
XCTAssertEqual(dockVisible.activationPolicy, .regular)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testPresentationPlanKeepsOneVisibleEntryPoint() {
|
||||||
|
let hidingMenuBar = AppDelegate.presentationPlan(
|
||||||
|
showMenuBarIcon: false,
|
||||||
|
showDockIcon: false,
|
||||||
|
changedSurface: .menuBar
|
||||||
|
)
|
||||||
|
XCTAssertFalse(hidingMenuBar.showMenuBarIcon)
|
||||||
|
XCTAssertTrue(hidingMenuBar.showDockIcon)
|
||||||
|
XCTAssertEqual(hidingMenuBar.activationPolicy, .regular)
|
||||||
|
|
||||||
|
let hidingDock = AppDelegate.presentationPlan(
|
||||||
|
showMenuBarIcon: false,
|
||||||
|
showDockIcon: false,
|
||||||
|
changedSurface: .dock
|
||||||
|
)
|
||||||
|
XCTAssertTrue(hidingDock.showMenuBarIcon)
|
||||||
|
XCTAssertFalse(hidingDock.showDockIcon)
|
||||||
|
XCTAssertEqual(hidingDock.activationPolicy, .accessory)
|
||||||
|
}
|
||||||
|
|
||||||
func testStatusItemMenuRoutingSeparatesLeftAndRightClick() {
|
func testStatusItemMenuRoutingSeparatesLeftAndRightClick() {
|
||||||
XCTAssertFalse(AppDelegate.shouldOpenStatusMenu(eventType: .leftMouseUp, modifierFlags: []))
|
XCTAssertFalse(AppDelegate.shouldOpenStatusMenu(eventType: .leftMouseUp, modifierFlags: []))
|
||||||
XCTAssertTrue(AppDelegate.shouldOpenStatusMenu(eventType: .rightMouseUp, modifierFlags: []))
|
XCTAssertTrue(AppDelegate.shouldOpenStatusMenu(eventType: .rightMouseUp, modifierFlags: []))
|
||||||
|
|||||||
28
tests/clipboredtests/SettingsModelTests.swift
Normal file
28
tests/clipboredtests/SettingsModelTests.swift
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import XCTest
|
||||||
|
@testable import ClipBored
|
||||||
|
|
||||||
|
final class SettingsModelTests: XCTestCase {
|
||||||
|
func testShowDockIconPersistsAndNotifies() {
|
||||||
|
let suiteName = "com.clipbored.settingsmodel.\(UUID().uuidString)"
|
||||||
|
let defaults = UserDefaults(suiteName: suiteName)!
|
||||||
|
defer {
|
||||||
|
defaults.removePersistentDomain(forName: suiteName)
|
||||||
|
}
|
||||||
|
let settings = SettingsModel(defaults: defaults)
|
||||||
|
var changes: [SettingsModel.Change] = []
|
||||||
|
settings.observe { changes.append($0) }
|
||||||
|
|
||||||
|
XCTAssertFalse(settings.showDockIcon)
|
||||||
|
|
||||||
|
settings.showDockIcon = true
|
||||||
|
|
||||||
|
XCTAssertTrue(defaults.bool(forKey: SettingsModel.Keys.showDockIcon))
|
||||||
|
XCTAssertEqual(changes.count, 1)
|
||||||
|
guard case .showDockIcon = changes.first else {
|
||||||
|
return XCTFail("Expected showDockIcon change notification")
|
||||||
|
}
|
||||||
|
|
||||||
|
let restored = SettingsModel(defaults: defaults)
|
||||||
|
XCTAssertTrue(restored.showDockIcon)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user