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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ These are handled once, in `panelHandlesGlobalKey`, which `FloatingPanel.sendEve
|-----|---------|
| `Esc` | In a text field, or while a shortcut is being recorded, cancel it (`Esc` with a modifier is recorded as the shortcut). Everywhere else, hide the panel: on every page, in every state, including setup, update and uninstall screens. Nothing else. |
| `⌘←` / `⌘→` | Previous / next top-level tab, without wrapping. While recording a hotkey they go to the recorder, which can't bind an arrow, so they do nothing. |
| `⌘,` | Open Settings, as in any Mac app. Works from a text field too; not while recording a hotkey, or on the setup and update screens. |
| `⌫` in an empty text field | Cancel the field, as `Esc` does. |

Cancelling a field puts it back the way it was before you started and hands the keyboard back to its page. Recording a shortcut counts as editing one: cancelling keeps the old shortcut. A search, filter or new-entry field empties. A field holding a value goes back to that value: a rename keeps the old name, an extension config field its saved value. `cancelField` in `Panel.swift` does this per page, so a page that adds a text field adds its case there. The key that cancelled, or the combo a recording took, may still be held when the field lets go, so `GlobalKeyRouter` swallows its autorepeat until a fresh press.
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,14 +136,15 @@ If you'd rather not click banners with the mouse, stack-nudge runs a small float

The panel is installed and registered as a launchd agent by `./install.sh` — no opt-in needed. To run quietly without macOS banners, toggle **Settings → Banner notifications** off (panel-only mode).

Default hotkey is `cmd+opt+n`. Hit it from anywhere to summon the panel; hit it again while focused to hide. Switch tabs with `Cmd+1` (Events), `Cmd+2` (Sessions), `Cmd+3` (Usage), `Cmd+4` (Outcomes), `Cmd+5` (Settings), or click them. Banner and panel can run together, alone, or both off; the sound and voice still fire as passive signals.
Default hotkey is `cmd+opt+n`. Hit it from anywhere to summon the panel; hit it again while focused to hide. Switch tabs with `Cmd+1` (Events), `Cmd+2` (Sessions), `Cmd+3` (Usage), `Cmd+4` (Outcomes), `Cmd+5` (Settings), or click them; `Cmd+,` also opens Settings, and so does **Settings…** in the menu bar menu. Banner and panel can run together, alone, or both off; the sound and voice still fire as passive signals.

These keys work the same on every page, including while a text field has focus:

| Key | Action |
|-----|--------|
| `Esc` | Hide the panel. In a text field it cancels the field instead: a filter, search or new phrase empties and hands the keyboard back to the page, a rename or config field keeps its saved value. While a shortcut is being recorded it cancels that and keeps the old one, and `Esc` with a modifier is recorded as the shortcut. The footer's last hint shows which. |
| `⌘←` / `⌘→` | Previous / next tab. From a sub-page, the tabs either side of the one it belongs to. Not while recording a hotkey, where they do nothing (a shortcut can't use an arrow), and not on the setup screen or Permissions opened from it, which have no tabs. |
| `⌘,` | Open Settings, as in any Mac app. Works from a text field too; not while recording a hotkey, or on the setup and update screens. |
| `⌫` in an empty text field | Cancel the field, as `Esc` does. A fresh press only: holding `⌫` to clear a field stops once it's empty. |

Outside a text field, `←` is back a level on sub-pages and detail panes: out of a detail pane, or from a sub-page to the page it belongs under. The exception is picking a value in Settings, where `←` / `→` step the value.
Expand Down Expand Up @@ -362,6 +363,7 @@ When the panel daemon is running, a bell icon appears in your menu bar. The same
| `Voice notifications` | Toggles spoken notifications. Enabling speaks *"Voice notifications enabled"*. |
| `Mute notifications ▸` | Submenu (For 15m / 30m / 1h / 2h) to silence all nudges for a set time. While muted the item becomes `Resume notifications` and the menu-bar icon shows a `bell.slash` + countdown. |
| `Show panel` | Brings the floating panel up (handy when no events are queued) |
| `Settings…` (⌘,) | Opens the panel straight on Settings |
| `Check permissions…` | Opens the Permissions page in the panel (see below) |
| `Open config file…` | Opens `~/.stack-nudge/config` in your default editor |
| `Quit stack-nudge panel` | Exits the daemon |
Expand Down
34 changes: 33 additions & 1 deletion Tests/StackNudgePanelCoreTests/GlobalKeyActionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import XCTest

// The keys that mean one thing on every page, text field or not. Raw virtual
// key codes, since the panel's KeyCode table is private: 53 Esc, 123 ←,
// 124 →, 51 delete, 0 "a".
// 124 →, 43 comma, 51 delete, 0 "a".
final class GlobalKeyActionTests: XCTestCase {

private func action(_ keyCode: UInt16,
Expand Down Expand Up @@ -66,6 +66,17 @@ final class GlobalKeyActionTests: XCTestCase {
XCTAssertNil(action(124, .command, recording: true))
}

// ⌘, is Settings from anywhere, a text field included. A recording may want
// it as a combo, and a held key shouldn't reopen Settings over and over.
func test_commandCommaOpensSettings() {
XCTAssertEqual(action(43, .command), .openSettings)
XCTAssertEqual(action(43, .command, inEmptyField: false), .openSettings)
XCTAssertNil(action(43, .command, recording: true))
XCTAssertNil(action(43, .command, isRepeat: true))
XCTAssertNil(action(43))
XCTAssertNil(action(43, [.command, .shift]))
}

// Plain arrows belong to the page, and ⇧⌘← to the field editor.
func test_otherArrowsAreLeftToThePage() {
XCTAssertNil(action(123))
Expand Down Expand Up @@ -104,6 +115,27 @@ final class GlobalKeyActionTests: XCTestCase {
XCTAssertNil(PanelController.stepTabTarget(from: .updating, by: -1, in: tabs))
}

private func canOpenSettings(_ mode: PanelMode,
returningTo returnMode: PanelMode = .settings) -> Bool {
PanelController.canOpenSettings(mode: mode, permissionsReturnMode: returnMode,
orderedTabs: [.events, .sessions, .settings])
}

// From a tab or a Settings sub-page, ⌘, and the menu go straight to Settings.
func test_settingsOpensFromATabOrSubPage() {
XCTAssertTrue(canOpenSettings(.sessions))
XCTAssertTrue(canOpenSettings(.phrases))
XCTAssertTrue(canOpenSettings(.permissions))
}

// Leaving setup or an install in progress would strand it until a relaunch.
func test_settingsStaysShutOnSetupAndUpdateScreens() {
XCTAssertFalse(canOpenSettings(.bootstrap))
XCTAssertFalse(canOpenSettings(.updating))
XCTAssertFalse(canOpenSettings(.postUpdate))
XCTAssertFalse(canOpenSettings(.permissions, returningTo: .bootstrap))
}

func test_ordinaryKeysPassThrough() {
XCTAssertNil(action(0))
XCTAssertNil(action(0, inEmptyField: true))
Expand Down
5 changes: 5 additions & 0 deletions panel/MenuBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ final class MenuBarController: NSObject, NSMenuDelegate {
menu.addItem(.separator())

menu.addItem(action("Show panel", #selector(showPanelAction)))
menu.addItem(action("Settings…", #selector(showSettingsAction), keyEquivalent: ","))
menu.addItem(action("Check permissions…", #selector(showPermissionsAction)))
menu.addItem(action("Open config file…", #selector(openConfigAction)))
menu.addItem(.separator())
Expand Down Expand Up @@ -390,6 +391,10 @@ final class MenuBarController: NSObject, NSMenuDelegate {
panelController?.showPanel()
}

@objc private func showSettingsAction() {
panelController?.showSettings()
}

@objc private func showPermissionsAction() {
panelController?.showPermissions()
}
Expand Down
34 changes: 33 additions & 1 deletion panel/Panel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3823,7 +3823,8 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,

// The keys that mean one thing on every page (see AGENTS.md, Keyboard
// navigation): Esc cancels a text field or, outside one, hides the panel,
// ⌘←/→ step through the tabs, and ⌫ in an empty text field cancels it too.
// ⌘←/→ step through the tabs, ⌘, opens Settings, and ⌫ in an empty text
// field cancels it too.
func panelHandlesGlobalKey(_ event: NSEvent, fieldEditor: NSTextView?) -> Bool {
let recording = nav.recordingHotkey || nav.recordingSpeakHotkey
switch globalKeys.route(event, recording: recording, fieldEditor: fieldEditor) {
Expand All @@ -3837,6 +3838,12 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
guard let target = Self.stepTabTarget(from: nav.mode, by: delta, in: nav.orderedTabs)
else { return false }
if target != nav.mode { nav.mode = target }
case .openSettings:
guard Self.canOpenSettings(mode: nav.mode,
permissionsReturnMode: nav.permissionsReturnMode,
orderedTabs: nav.orderedTabs)
else { return false }
nav.mode = .settings
case .cancelField:
cancelField()
case nil:
Expand Down Expand Up @@ -3927,11 +3934,21 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
return tabs.indices.contains(index + delta) ? tabs[index + delta] : mode
}

// Where ⌘, and the menu's Settings… may take over the screen. Same rule as
// ⌘←/→: setup, the update screens and Permissions opened from setup have
// no tab strip, and leaving them would strand setup until a relaunch.
static func canOpenSettings(mode: PanelMode, permissionsReturnMode: PanelMode,
orderedTabs: [PanelMode]) -> Bool {
if mode == .permissions, permissionsReturnMode == .bootstrap { return false }
return stepTabTarget(from: mode, by: 0, in: orderedTabs) != nil
}

enum GlobalKeyAction: Equatable {
// A repeat of a key that already ended something; see GlobalKeyRouter.
case swallowRepeat
case hide
case stepTab(Int)
case openSettings
case cancelField
}

Expand All @@ -3954,6 +3971,8 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
return .stepTab(-1)
case KeyCode.rightArrow where modifiers == .command && !recording:
return .stepTab(1)
case KeyCode.comma where modifiers == .command && !recording && !isRepeat:
return .openSettings
case KeyCode.delete where modifiers.isEmpty && inEmptyField == true && !isRepeat:
return .cancelField
default:
Expand Down Expand Up @@ -5122,6 +5141,19 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate,
}
}

// Straight to Settings from outside the panel (the menu bar menu),
// optionally on one category, rather than open-the-panel-then-find-the-tab.
// On setup or an update screen it only brings the panel up, as ⌘, does.
func showSettings(category: SettingsCategory? = nil) {
if Self.canOpenSettings(mode: nav.mode,
permissionsReturnMode: nav.permissionsReturnMode,
orderedTabs: nav.orderedTabs) {
nav.mode = .settings
if let category { nav.settingsCategory = category }
}
showPanel()
}

func showPanel() {
// In compact mode, "show" means "expand" — bringing back the full
// panel rather than just raising the pill (which is already visible).
Expand Down
Loading