Skip to content
Open
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
36 changes: 36 additions & 0 deletions docs/adr/0010-spm-build-with-shell-script-app-bundling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# ADR 0010 — Swift Package Manager build with shell-script app bundling

**Date:** 2026-04-13
**Status:** Accepted

## Context

Snippy is a macOS GUI application with an `NSPanel`, menu bar icon, and a full SwiftUI view hierarchy. macOS GUI apps are conventionally built with Xcode projects (`.xcodeproj` or `.xcworkspace`), which provide automatic app bundle creation, code signing, asset catalog compilation, and Interface Builder integration.

However, Snippy has no external dependencies, no asset catalogs, no storyboards, and no test target. The entire app is ~1,100 lines of Swift across 10 source files. An Xcode project for this scope is mostly boilerplate — the `.xcodeproj` directory alone would be larger than several source files combined.

Alternative approaches considered:

- **Xcode project** — full IDE integration and automatic `.app` bundle generation, but adds ~20 config files, merge-conflict-prone `pbxproj`, and requires Xcode (not just Command Line Tools) to build.
- **Swift Package Manager only** — builds an executable binary via `swift build`, but SPM has no concept of `.app` bundles, `Info.plist`, or macOS application packaging.
- **SPM + shell script** — SPM compiles the Swift code; a short shell script assembles the `.app` bundle structure, copies the binary and `Info.plist`, and optionally installs to `/Applications`.

## Decision

Use `Package.swift` (swift-tools-version 6.0, Swift language mode 5) as the build system and `build.sh` as the app-bundling step. The package defines a single executable target with all sources in `Sources/`. The shell script:

1. Runs `swift build -c release`.
2. Creates the `.app/Contents/MacOS` and `.app/Contents/Resources` directory structure.
3. Copies the compiled binary from `.build/release/Snippy` into the bundle.
4. Copies `Info.plist` into `.app/Contents/`.
5. Writes a `PkgInfo` file (`APPL????`).
6. Optionally kills any running instance and installs to `/Applications`.

## Consequences

- Building requires only Xcode Command Line Tools (`xcode-select --install`), not the full Xcode IDE. `swift build` works from any terminal.
- There is no `.xcodeproj` to maintain or merge. The `Package.swift` is 14 lines.
- Code signing is not handled. The app runs unsigned, which is fine for local development but means Gatekeeper will block it for other users unless they right-click → Open. Distribution via DMG would require a separate signing step.
- Adding resources (images, asset catalogs, localisation bundles) would require extending `build.sh` to copy them into `.app/Contents/Resources`. SPM's resource bundling (`Bundle.module`) does not produce a macOS app bundle layout.
- `Info.plist` is maintained manually at the repo root. Changes to bundle metadata (version, `LSUIElement`, ATS settings) require editing the plist directly rather than through Xcode's GUI.
- Debug builds via `swift build` (without `build.sh`) produce a bare executable that works but has no app bundle identity — it cannot display a menu bar icon correctly in all macOS versions. `build.sh` is needed for a proper install.
45 changes: 45 additions & 0 deletions docs/adr/0011-zstack-overlay-for-inline-label-coloring.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# ADR 0011 — ZStack overlay for real-time inline label coloring

**Date:** 2026-04-13
**Status:** Accepted

## Context

Snippy's add and edit rows show a real-time color highlight as the user types: the label portion (before `": "`) appears in an accent color while the value portion stays in the primary color. This gives immediate visual feedback that the parser has detected a label, without requiring a separate label field.

SwiftUI's `TextField` does not support attributed strings or partial text coloring. The entire text field can have one `foregroundColor`, but there is no built-in way to color a substring differently.

Approaches considered:

- **NSTextField with NSAttributedString** — drop down to AppKit, use `NSMutableAttributedString` to set per-range colors. Works, but breaks SwiftUI's declarative data flow — the binding between `@State` and the attributed string must be managed manually, and the colored text must be re-applied on every keystroke.
- **Multiple adjacent TextFields** — split input into a label `TextField` and a value `TextField` side by side. Semantically clean but disrupts the single-field typing experience; the user would need to tab between fields and could not type `Label: value` in one continuous flow.
- **ZStack with transparent TextField + colored Text overlay** — keep a single `TextField` bound to the full input string, but set its `foregroundColor` to `.clear` when a label is detected. Layer a `Text` view on top (via `ZStack(alignment: .leading)`) that renders the label in accent color, the separator in secondary color, and the value in primary color. Disable hit-testing on the overlay so clicks and selections still reach the `TextField`.

## Decision

Use the ZStack overlay pattern in both `AddSnippetRow` and `EditSnippetRow`:

```swift
ZStack(alignment: .leading) {
TextField("value or label: value", 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)
}
}
```

The overlay is conditionally shown only when `SnippyParser.parse(input)` returns a non-nil result. When no label is detected, the `TextField` renders normally with `.primary` color.

## Consequences

- The user types in a single field with no mode switches. Label detection is instant and non-disruptive — the colors update on every keystroke as SwiftUI re-evaluates the `parsed` computed property.
- `.allowsHitTesting(false)` on the overlay ensures the `TextField` remains fully interactive: cursor placement, text selection, and drag all work as expected because input events pass through the overlay.
- The `Text` overlay and the `TextField` must use identical font metrics (`.system(size: 13, weight: .medium, design: .monospaced)`) so the colored text aligns exactly with the invisible original text. A mismatch would cause visible misalignment.
- `AddSnippetRow` uses `.accentColor` for the label; `EditSnippetRow` uses `.orange`. This visual distinction helps the user tell at a glance whether they are adding or editing.
- The pattern works only for single-line text. If multi-line snippet editing were added, the overlay alignment would break because `Text` and `TextField` handle line wrapping differently.
- The cursor and selection highlight are rendered by the underlying `TextField` in its `.clear` foreground color — they remain visible because the cursor and selection use system colors, not the text foreground color.