WIP: add Dock visibility setting

This commit is contained in:
Akshay Kolli
2026-06-30 02:25:52 -07:00
parent 3c4e4741d6
commit 3b6af36d18
6 changed files with 152 additions and 5 deletions

View File

@@ -1,12 +1,12 @@
# 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.
## 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
- Global shortcuts:
- `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
- Copy and paste actions with Accessibility permission fallback
- 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
## Requirements

View File

@@ -1,6 +1,17 @@
import AppKit
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 {
let summary: String
let detail: String?
@@ -61,6 +72,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
settingsShortcut: settings.settingsShortcut
)
bindSettings()
applyPresentation(changedSurface: nil)
monitor.setPaused(settings.pauseCapture)
monitor.start()
shortcutManager.start()
@@ -415,7 +427,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
case .launchAtLogin:
applyLaunchAtLoginSetting(settings.launchAtLogin)
case .showMenuBarIcon:
refreshStatusItem()
applyPresentation(changedSurface: .menuBar)
case .showDockIcon:
applyPresentation(changedSurface: .dock)
case .pauseCapture:
monitor.setPaused(settings.pauseCapture)
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) {
let result = lifecycleService.applyLaunchAtLogin(shouldLaunch)
switch result {

View File

@@ -8,6 +8,7 @@ final class SettingsModel {
case settingsShortcut
case launchAtLogin
case showMenuBarIcon
case showDockIcon
case pauseCapture
case pollProfile
case captureStatus
@@ -23,6 +24,7 @@ final class SettingsModel {
static let pruneDuplicates = "pruneDuplicates"
static let launchAtLogin = "launchAtLogin"
static let showMenuBarIcon = "showMenuBarIcon"
static let showDockIcon = "showDockIcon"
static let openShortcut = "openShortcut"
static let settingsShortcut = "settingsShortcut"
static let ignoredApps = "ignoredApps"
@@ -56,6 +58,9 @@ final class SettingsModel {
var showMenuBarIcon: Bool {
didSet { if oldValue != showMenuBarIcon { storeAndNotify(.showMenuBarIcon) } }
}
var showDockIcon: Bool {
didSet { if oldValue != showDockIcon { storeAndNotify(.showDockIcon) } }
}
var openShortcut: ShortcutBinding {
didSet { if oldValue != openShortcut { storeAndNotify(.openShortcut) } }
}
@@ -107,6 +112,7 @@ final class SettingsModel {
pruneDuplicates = defaults.object(forKey: Keys.pruneDuplicates) as? Bool ?? true
launchAtLogin = defaults.object(forKey: Keys.launchAtLogin) as? Bool ?? false
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
settingsShortcut = Self.readShortcut(from: defaults.string(forKey: Keys.settingsShortcut)) ?? AppConfiguration.defaultSettingsShortcut
ignoredApps = defaults.stringArray(forKey: Keys.ignoredApps) ?? AppConfiguration.defaultIgnoredApps
@@ -135,6 +141,7 @@ final class SettingsModel {
defaults.set(pruneDuplicates, forKey: Keys.pruneDuplicates)
defaults.set(launchAtLogin, forKey: Keys.launchAtLogin)
defaults.set(showMenuBarIcon, forKey: Keys.showMenuBarIcon)
defaults.set(showDockIcon, forKey: Keys.showDockIcon)
defaults.set(openShortcut.encoded(), forKey: Keys.openShortcut)
defaults.set(settingsShortcut.encoded(), forKey: Keys.settingsShortcut)
defaults.set(ignoredApps, forKey: Keys.ignoredApps)

View File

@@ -13,6 +13,7 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD
private let defaultSortPopup = NSPopUpButton()
private let launchAtLoginButton = NSButton()
private let showMenuBarIconButton = NSButton()
private let showDockIconButton = NSButton()
private let launchStatusLabel = NSTextField(labelWithString: "")
private var openShortcutControls: ShortcutControlSet?
@@ -124,6 +125,7 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD
}
configureCheckbox(launchAtLoginButton, title: "Launch at login", action: #selector(launchAtLoginChanged))
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)
return page([
@@ -138,6 +140,7 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD
section("Lifecycle", [
launchAtLoginButton,
showMenuBarIconButton,
showDockIconButton,
launchStatusLabel
])
])
@@ -392,6 +395,7 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD
select(defaultSortPopup, rawValue: settings.defaultSortMode.rawValue)
launchAtLoginButton.state = settings.launchAtLogin ? .on : .off
showMenuBarIconButton.state = settings.showMenuBarIcon ? .on : .off
showDockIconButton.state = settings.showDockIcon ? .on : .off
launchStatusLabel.stringValue = settings.launchAtLoginErrorMessage
refreshShortcutControls(openShortcutControls, binding: settings.openShortcut)
@@ -468,7 +472,19 @@ final class SettingsWindowController: NSObject, NSTextFieldDelegate, NSTextViewD
}
@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) {

View File

@@ -3,6 +3,46 @@ import XCTest
@testable import ClipBored
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() {
XCTAssertFalse(AppDelegate.shouldOpenStatusMenu(eventType: .leftMouseUp, modifierFlags: []))
XCTAssertTrue(AppDelegate.shouldOpenStatusMenu(eventType: .rightMouseUp, modifierFlags: []))

View 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)
}
}