diff --git a/docs/adr/0008-nsevent-local-monitor-for-keyboard-input.md b/docs/adr/0008-nsevent-local-monitor-for-keyboard-input.md new file mode 100644 index 0000000..4ebf374 --- /dev/null +++ b/docs/adr/0008-nsevent-local-monitor-for-keyboard-input.md @@ -0,0 +1,32 @@ +# ADR 0008 — NSEvent local monitor for all keyboard input + +**Date:** 2026-04-12 +**Status:** Accepted + +## Context + +Snippy's UI runs inside an `NSPanel` with the `.nonactivatingPanel` style mask (see ADR 0002). SwiftUI provides several built-in mechanisms for handling keyboard input: + +- **`.onSubmit`** — fires when the user presses Return inside a focused `TextField`. In a standard `NSWindow` this works reliably. +- **`.onKeyPress` (macOS 14+)** — attaches a handler for arbitrary key events to any SwiftUI view. +- **`@FocusState` + `focused()` modifier** — manages first-responder state declaratively. + +However, in non-activating `NSPanel` windows these mechanisms are unreliable. `.onSubmit` and `.onKeyPress` silently fail to fire because the SwiftUI responder chain does not always connect properly when the hosting window is non-activating. `@FocusState` may not update after the panel is hidden and reshown. + +The alternative is to drop to AppKit and use `NSEvent.addLocalMonitorForEvents(matching: .keyDown)`, which intercepts key events at the application level before they enter the responder chain. This works regardless of window type. + +## Decision + +Use `NSEvent.addLocalMonitorForEvents(matching: .keyDown)` for **all** keyboard handling throughout the app: + +- **`ContentView`** installs a local monitor on appear for arrow keys (126/125), Return (36), Escape (53), `⌘V` (9), `⌘N` (45), and `⌘Q` (12). The monitor skips interception when `isAdding` or `editingID != nil` so it does not conflict with the add/edit row monitors. +- **`ReturnKeyModifier` (`OnReturnKey`)** provides a reusable `.onReturnKey {}` view modifier that installs its own local monitor scoped to the modifier's lifetime (`onAppear` / `onDisappear`). Used by `AddSnippetRow` and `EditSnippetRow`. +- Returning `nil` from the monitor callback consumes the event; returning the event lets it propagate to other responders. + +## Consequences + +- Keyboard shortcuts work identically whether the panel is first shown, reshown after hiding, or activated from a different app. +- Multiple monitors coexist: `ContentView`'s monitor yields to the add/edit monitors by checking `isAdding` / `editingID`, and each `OnReturnKey` monitor only intercepts keyCode 36. Care must be taken when adding new monitors to avoid double-handling. +- Raw key codes (integers) are used instead of symbolic constants, which is less readable. A comment next to each code documents the key name. +- Every monitor must be removed in `onDisappear` (or the equivalent cleanup path) to avoid leaking event taps. `ContentView` and `OnReturnKey` both follow this pattern. +- If a future macOS release fixes SwiftUI key handling in non-activating panels, the monitors could be replaced with declarative SwiftUI handlers. Until then, this approach is the most reliable option. diff --git a/docs/adr/0009-use-count-sorting-for-snippet-order.md b/docs/adr/0009-use-count-sorting-for-snippet-order.md new file mode 100644 index 0000000..26ed5a2 --- /dev/null +++ b/docs/adr/0009-use-count-sorting-for-snippet-order.md @@ -0,0 +1,29 @@ +# ADR 0009 — Use-count sorting as the default snippet order + +**Date:** 2026-04-12 +**Status:** Accepted + +## Context + +Snippy needs a default ordering for the snippet list. The ordering determines which snippets the user sees first when the panel opens and directly affects how quickly they can find and copy the one they need. + +Common ordering strategies for a personal snippet manager: + +- **Chronological (newest first)** — intuitive after adding, but frequently used snippets sink to the bottom over time and require scrolling or searching. +- **Alphabetical** — predictable but bears no relation to usage frequency. A rarely used snippet starting with "A" always appears before a daily-use snippet starting with "Z". +- **Manual (drag to reorder)** — maximally flexible but requires the user to actively maintain the order. Becomes tedious as the library grows. +- **Frequency-based (most used first)** — the list self-organises around actual usage. Snippets the user copies often float to the top automatically, reducing navigation for the most common workflows. + +## Decision + +Sort snippets by `useCount` descending in `SnippetStore.filtered(by:)`. Every call to `copySnippet()` increments the snippet's `useCount` and records `lastUsedAt`, then persists via `save()`. The sorted order is recomputed on every access (no cached sort index). + +New snippets start with `useCount = 0` and appear at the bottom of the list until they accumulate enough copies to rise. The `move(fromOffsets:toOffset:)` method exists in `SnippetStore` for potential future manual reordering but is not wired to any UI today. + +## Consequences + +- The most-copied snippets naturally appear at the top, which optimises for the common case (the user opens Snippy to copy something they copy often). +- Newly added snippets start at the bottom, which can be surprising. A user who just added a snippet must scroll or search to find it. This is a known trade-off; the `suggest-improvements` list includes a "pin/favourite" feature as a potential mitigation. +- Sorting is O(n log n) on every `filtered(by:)` call. For expected library sizes (tens to low hundreds of snippets) this is imperceptible. +- Ties in `useCount` are broken by the array's existing insertion order (newest first for equal counts), giving a reasonable secondary sort without extra logic. +- The `lastUsedAt` timestamp is recorded but not currently used for sorting. It is available for future features such as a recency-weighted score or a "last used" display.