diff --git a/docs/adr/0008-nsevent-local-monitors-for-keyboard-input.md b/docs/adr/0008-nsevent-local-monitors-for-keyboard-input.md new file mode 100644 index 0000000..af1a073 --- /dev/null +++ b/docs/adr/0008-nsevent-local-monitors-for-keyboard-input.md @@ -0,0 +1,41 @@ +# ADR 0008 — NSEvent local monitors for all keyboard input + +**Date:** 2026-04-13 +**Status:** Accepted + +## Context + +Snippy's UI runs inside an `NSPanel` with `.nonactivatingPanel` style (see ADR 0002). In this configuration, SwiftUI's standard keyboard handling APIs — `.onSubmit`, `.onKeyPress`, and `@FocusState`-driven responders — do not fire reliably. The SwiftUI responder chain depends on the hosting window being a regular key window in an active application, which a non-activating panel is not. + +The app needs to handle a substantial set of keyboard inputs: + +- Arrow keys for snippet navigation (`↑`/`↓`) +- Return to copy the selected snippet +- Escape to dismiss the panel or cancel add/edit +- `⌘V` to paste a new snippet from the clipboard +- `⌘N` to start adding a new snippet +- `⌘Q` to quit +- Return inside add/edit rows to save + +Two approaches were considered: + +- **Subclass NSTextField and override `keyDown(_:)`** — requires dropping SwiftUI's `TextField` in favour of an AppKit text field wrapped in `NSViewRepresentable`. Increases code complexity and loses SwiftUI binding convenience. +- **`NSEvent.addLocalMonitorForEvents(matching: .keyDown)`** — installs a closure that intercepts key events before they reach the responder chain. Works regardless of focus state or window type. Returns `nil` to consume the event or the event itself to let it propagate. + +## Decision + +Use `NSEvent.addLocalMonitorForEvents(matching: .keyDown)` for all keyboard handling. Three separate monitors are installed depending on context: + +1. **ContentView** — handles navigation, copy, dismiss, paste, new, and quit. Installed on `onAppear`, removed on `onDisappear`. Skips interception when `isAdding` or `editingID != nil` (except Escape). +2. **ReturnKeyModifier (`OnReturnKey`)** — a reusable `ViewModifier` that monitors for the Return key only. Used by `AddSnippetRow` and `EditSnippetRow` to save on Enter. +3. The monitors are layered: when an add/edit row is active, its `OnReturnKey` monitor consumes Return before the ContentView monitor sees it; ContentView's monitor defers all other keys to the add/edit row's TextField. + +Key codes are used directly (36 = Return, 53 = Escape, 126 = Up, 125 = Down, 9 = V, 45 = N, 12 = Q) rather than character matching, because `characters` can vary with keyboard layouts while key codes are layout-independent. + +## Consequences + +- Keyboard handling works identically in the non-activating panel as it would in a regular window. +- SwiftUI `TextField` remains usable for text input — the monitors only intercept specific key codes and pass everything else through. +- Adding a new keyboard shortcut requires knowing the key code and adding a case to the appropriate monitor's switch statement. +- Multiple monitors active at the same time can cause ordering issues if they both handle the same key code. The current design avoids this by having ContentView's monitor defer to add/edit monitors via the `isAdding`/`editingID` guard. +- The monitors must be explicitly removed on `onDisappear` to prevent leaks and ghost handlers. diff --git a/docs/adr/0009-zstack-overlay-for-inline-label-coloring.md b/docs/adr/0009-zstack-overlay-for-inline-label-coloring.md new file mode 100644 index 0000000..6b8ad02 --- /dev/null +++ b/docs/adr/0009-zstack-overlay-for-inline-label-coloring.md @@ -0,0 +1,41 @@ +# ADR 0009 — ZStack overlay for inline label coloring + +**Date:** 2026-04-13 +**Status:** Accepted + +## Context + +When a user types `Label: value` in the add or edit field, Snippy highlights the label portion in a distinct colour in real time (accent blue in add mode, orange in edit mode). The challenge is that SwiftUI's `TextField` does not support attributed strings or partial text colouring — the entire field has a single `foregroundColor`. + +Three approaches were considered: + +- **NSViewRepresentable wrapping NSTextField with NSAttributedString** — allows per-character styling but requires bridging AppKit's `NSTextField` into SwiftUI, handling focus, bindings, and attributed string updates manually. Significant complexity for a cosmetic feature. +- **SwiftUI `Text` with `AttributedString`** — `Text` supports `AttributedString` for inline styling, but `TextField` does not accept `AttributedString` as its binding value. Using a `Text` as a read-only display would lose editability. +- **ZStack with invisible TextField and visible styled Text overlay** — render the `TextField` with `.foregroundColor(.clear)` so the text is invisible but the cursor and selection remain functional. Layer a styled `Text` view on top using `Text(label).foregroundColor(.accent) + Text(": ") + Text(value)` to show the coloured rendering. Disable hit testing on the overlay with `.allowsHitTesting(false)` so clicks and selections go through to the underlying TextField. + +## Decision + +Use the ZStack overlay approach. Both `AddSnippetRow` and `EditSnippetRow` implement it identically: + +```swift +ZStack(alignment: .leading) { + TextField("placeholder", text: $input) + .foregroundColor(parsed != nil ? .clear : .primary) + if let p = parsed { + (Text(p.label).foregroundColor(.accentColor) + + Text(": ").foregroundColor(.secondary) + + Text(p.value).foregroundColor(.primary)) + .allowsHitTesting(false) + } +} +``` + +When `SnippyParser.parse(input)` returns `nil` (no valid label detected), the TextField uses its normal `.primary` foreground colour and no overlay is shown. When a label is detected, the TextField goes clear and the overlay renders the styled version. + +## Consequences + +- The coloured label preview updates on every keystroke with no perceptible lag, since `SnippyParser.parse` is a pure function with negligible cost. +- The TextField remains fully interactive — cursor positioning, text selection, and keyboard input work normally because the overlay has hit testing disabled. +- The overlay and the hidden text must use the same font and alignment; any mismatch causes visible misalignment between the cursor and the rendered text. Both rows use `.font(.system(size: 13, weight: .medium, design: .monospaced))` consistently. +- Monospaced font is critical: proportional fonts would cause the overlay characters to drift from the invisible TextField characters, making the cursor appear in the wrong position. +- The approach is purely visual — the underlying `input` binding contains the raw unstyled string. `SnippyParser` handles the actual split when saving.