From 97b7cc00f6ece1bc638484d0d533b082c6c7e9da Mon Sep 17 00:00:00 2001 From: Philipp Klocke Date: Sat, 30 May 2026 21:12:47 +0200 Subject: [PATCH] Split trigger-zone into separate untrigger-zone for click restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit triggerZone was serving double duty: hiding the bar when the cursor entered the top N px, and also as the guard that prevented a click from restoring the bar. These two roles have conflicting ideal values. - Hiding needs a small zone (~10px) so the bar only hides when you're genuinely approaching the menu bar edge. - Click restore needs a larger zone (~30px, the macOS menu bar height) so that clicking File/Edit/… while the bar is hidden doesn't snap it back mid-navigation. Using a single value forced a bad tradeoff: 10px meant menu clicks restored the bar; 30px meant the bar hid on any casual hover near the top. Adds untriggerZone (default 30px): a click while hidden only restores the bar if the cursor is at or below this threshold. triggerZone continues to control hiding exclusively. Both are independently configurable via --trigger-zone and --untrigger-zone. Co-Authored-By: Claude Sonnet 4.6 --- Sources/SketchyBarToggle/main.swift | 6 ++-- Sources/SketchyBarToggleCore/Config.swift | 6 ++++ .../SketchyBarToggleCore/StateMachine.swift | 5 +++- Tests/SketchyBarToggleTests/ConfigTests.swift | 6 ++++ .../StateMachineTests.swift | 28 ++++++++++++++----- 5 files changed, 41 insertions(+), 10 deletions(-) diff --git a/Sources/SketchyBarToggle/main.swift b/Sources/SketchyBarToggle/main.swift index 11954c5..f86dd44 100644 --- a/Sources/SketchyBarToggle/main.swift +++ b/Sources/SketchyBarToggle/main.swift @@ -44,6 +44,7 @@ let controller = SketchyBarController() let stateMachine = BarStateMachine( controller: controller, triggerZone: config.triggerZone, + untriggerZone: config.untriggerZone, menuBarHeight: config.menuBarHeight, debounceInterval: config.debounce ) @@ -72,7 +73,7 @@ if config.debug || ProcessInfo.processInfo.environment["SKETCHYBAR_TOGGLE_DEBUG" let line = "\(ISO8601DateFormatter().string(from: Date())) \(msg)\n" debugLogFile?.write(line.data(using: .utf8) ?? Data()) } - debugLog?("started with trigger=\(Int(config.triggerZone)) menuBar=\(Int(config.menuBarHeight)) debounce=\(Int(config.debounce * 1000))ms") + debugLog?("started with trigger=\(Int(config.triggerZone)) untrigger=\(Int(config.untriggerZone)) menuBar=\(Int(config.menuBarHeight)) debounce=\(Int(config.debounce * 1000))ms") } else { debugLog = nil } @@ -80,7 +81,7 @@ if config.debug || ProcessInfo.processInfo.environment["SKETCHYBAR_TOGGLE_DEBUG" let monitor = EventTapMonitor(stateMachine: stateMachine, debugLog: debugLog) monitor.start() -print("sketchybar-toggle v\(version) running (trigger: \(Int(config.triggerZone))px, menu bar: \(Int(config.menuBarHeight))px, debounce: \(Int(config.debounce * 1000))ms)") +print("sketchybar-toggle v\(version) running (trigger: \(Int(config.triggerZone))px, untrigger: \(Int(config.untriggerZone))px, menu bar: \(Int(config.menuBarHeight))px, debounce: \(Int(config.debounce * 1000))ms)") if config.debug { print("Debug logging to /tmp/sketchybar-toggle-debug.log") } print("Press Ctrl+C to stop.") @@ -110,6 +111,7 @@ func printUsage() { Options: --trigger-zone Pixels from top of screen to trigger hide (default: 10) + --untrigger-zone Pixels from top a click must clear to restore the bar (default: 30) --menu-bar-height Pixels from top defining menu bar zone (default: 50) --debounce Debounce delay in milliseconds (default: 150) --check-permissions Check permissions (no longer needed in v0.4.0+) diff --git a/Sources/SketchyBarToggleCore/Config.swift b/Sources/SketchyBarToggleCore/Config.swift index 30ef705..c952d6c 100644 --- a/Sources/SketchyBarToggleCore/Config.swift +++ b/Sources/SketchyBarToggleCore/Config.swift @@ -2,6 +2,7 @@ import Foundation public struct Config: Equatable { public var triggerZone: CGFloat = 10 + public var untriggerZone: CGFloat = 30 public var menuBarHeight: CGFloat = 50 public var debounce: TimeInterval = 0.15 public var checkPermissions = false @@ -29,6 +30,11 @@ public func parseArguments(_ args: [String]) throws -> Config { if i < args.count, let val = Double(args[i]) { config.triggerZone = CGFloat(val) } + case "--untrigger-zone": + i += 1 + if i < args.count, let val = Double(args[i]) { + config.untriggerZone = CGFloat(val) + } case "--menu-bar-height": i += 1 if i < args.count, let val = Double(args[i]) { diff --git a/Sources/SketchyBarToggleCore/StateMachine.swift b/Sources/SketchyBarToggleCore/StateMachine.swift index 4d38af1..4812bfc 100644 --- a/Sources/SketchyBarToggleCore/StateMachine.swift +++ b/Sources/SketchyBarToggleCore/StateMachine.swift @@ -11,6 +11,7 @@ public final class BarStateMachine { public private(set) var state: BarState = .visible public let triggerZone: CGFloat + public let untriggerZone: CGFloat public let menuBarHeight: CGFloat public let debounceInterval: TimeInterval @@ -21,12 +22,14 @@ public final class BarStateMachine { public init( controller: BarController, triggerZone: CGFloat = 10, + untriggerZone: CGFloat = 30, menuBarHeight: CGFloat = 50, debounceInterval: TimeInterval = 0.15, timerQueue: DispatchQueue = .main ) { self.controller = controller self.triggerZone = triggerZone + self.untriggerZone = untriggerZone self.menuBarHeight = menuBarHeight self.debounceInterval = debounceInterval self.timerQueue = timerQueue @@ -56,7 +59,7 @@ public final class BarStateMachine { /// If the click is outside the trigger zone, immediately restore SketchyBar /// so that window title bars near the top of the screen remain interactive. public func handleMouseClick(distanceFromTop: CGFloat) { - guard state == .hidden, distanceFromTop >= triggerZone else { return } + guard state == .hidden, distanceFromTop >= untriggerZone else { return } cancelDebounce() state = .visible controller.show() diff --git a/Tests/SketchyBarToggleTests/ConfigTests.swift b/Tests/SketchyBarToggleTests/ConfigTests.swift index 0224ec9..f0be0f1 100644 --- a/Tests/SketchyBarToggleTests/ConfigTests.swift +++ b/Tests/SketchyBarToggleTests/ConfigTests.swift @@ -6,6 +6,7 @@ final class ConfigTests: XCTestCase { func testDefaultConfig() throws { let config = try parseArguments([]) XCTAssertEqual(config.triggerZone, 10) + XCTAssertEqual(config.untriggerZone, 30) XCTAssertEqual(config.menuBarHeight, 50) XCTAssertEqual(config.debounce, 0.15) XCTAssertFalse(config.checkPermissions) @@ -24,6 +25,11 @@ final class ConfigTests: XCTestCase { XCTAssertEqual(config.menuBarHeight, 44) } + func testUntriggerZone() throws { + let config = try parseArguments(["--untrigger-zone", "25"]) + XCTAssertEqual(config.untriggerZone, 25) + } + func testDebounceConvertsMillisecondsToSeconds() throws { let config = try parseArguments(["--debounce", "300"]) XCTAssertEqual(config.debounce, 0.3) diff --git a/Tests/SketchyBarToggleTests/StateMachineTests.swift b/Tests/SketchyBarToggleTests/StateMachineTests.swift index 9f9c614..8b0ebb4 100644 --- a/Tests/SketchyBarToggleTests/StateMachineTests.swift +++ b/Tests/SketchyBarToggleTests/StateMachineTests.swift @@ -247,27 +247,40 @@ final class StateMachineTests: XCTestCase { // MARK: - Click-to-restore behavior - func testClickInHiddenStateOutsideTriggerZoneRestoresBar() { + func testClickInHiddenStateOutsideUntriggerZoneRestoresBar() { let mock = MockBarController() - let sm = BarStateMachine(controller: mock, triggerZone: 10, menuBarHeight: 50) + let sm = BarStateMachine(controller: mock, triggerZone: 10, untriggerZone: 30, menuBarHeight: 50) sm.handleMousePosition(distanceFromTop: 5) // hide XCTAssertEqual(sm.state, .hidden) mock.reset() - sm.handleMouseClick(distanceFromTop: 30) // click in menu bar zone but outside trigger + sm.handleMouseClick(distanceFromTop: 40) // click below untrigger zone XCTAssertEqual(sm.state, .visible) XCTAssertEqual(mock.showCallCount, 1) } - func testClickInHiddenStateInsideTriggerZoneDoesNotRestore() { + func testClickInHiddenStateInsideUntriggerZoneDoesNotRestore() { let mock = MockBarController() - let sm = BarStateMachine(controller: mock, triggerZone: 10, menuBarHeight: 50) + let sm = BarStateMachine(controller: mock, triggerZone: 10, untriggerZone: 30, menuBarHeight: 50) sm.handleMousePosition(distanceFromTop: 5) // hide XCTAssertEqual(sm.state, .hidden) - sm.handleMouseClick(distanceFromTop: 5) // click still in trigger zone (using menu bar) + sm.handleMouseClick(distanceFromTop: 20) // click inside untrigger zone (menu bar area) + XCTAssertEqual(sm.state, .hidden) + } + + func testClickBetweenTriggerAndUntriggerZoneDoesNotRestore() { + // Key split-threshold test: triggerZone=10, untriggerZone=30. + // A click at 15px is outside triggerZone but inside untriggerZone — should NOT restore. + let mock = MockBarController() + let sm = BarStateMachine(controller: mock, triggerZone: 10, untriggerZone: 30, menuBarHeight: 50) + + sm.handleMousePosition(distanceFromTop: 5) // hide + XCTAssertEqual(sm.state, .hidden) + + sm.handleMouseClick(distanceFromTop: 15) // between the two thresholds XCTAssertEqual(sm.state, .hidden) } @@ -285,6 +298,7 @@ final class StateMachineTests: XCTestCase { let sm = BarStateMachine( controller: mock, triggerZone: 10, + untriggerZone: 30, menuBarHeight: 50, debounceInterval: 0.1 ) @@ -300,7 +314,7 @@ final class StateMachineTests: XCTestCase { func testClickBelowMenuBarZoneRestoresBar() { let mock = MockBarController() - let sm = BarStateMachine(controller: mock, triggerZone: 10, menuBarHeight: 50) + let sm = BarStateMachine(controller: mock, triggerZone: 10, untriggerZone: 30, menuBarHeight: 50) sm.handleMousePosition(distanceFromTop: 5) // hide mock.reset()