|
| 1 | +## RULES |
| 2 | + |
| 3 | +### Compatibility (non-negotiable) |
| 4 | +- **Swift 6.0 compatible**: `swift-tools-version: 6.0`. Never use features that require a newer compiler. |
| 5 | +- **Cross-platform**: must build and run without crashes/segfaults on both macOS and Linux. CI tests both (`macos-15` + `swift:6.0` container). |
| 6 | +- When in doubt, verify with the CI pipeline before merging. |
| 7 | + |
| 8 | +### Pre-Push Verification (non-negotiable) |
| 9 | +- **Before pushing to GitHub**: ALWAYS run `./scripts/test-linux.sh` to verify build + tests pass on both macOS and Linux. |
| 10 | +- The script runs `swift build` and `swift test` natively on macOS, then repeats both inside a `swift:6.0` Docker container (same image as CI). |
| 11 | +- **Never push code that has not been verified on both platforms.** |
| 12 | +- Usage: `./scripts/test-linux.sh` (both), `./scripts/test-linux.sh linux` (Linux only), `./scripts/test-linux.sh shell` (interactive Linux shell) |
| 13 | +- Requires Docker Desktop to be running. |
| 14 | + |
| 15 | +### Architecture (non-negotiable) |
| 16 | + |
| 17 | +#### General Principles |
| 18 | +- No Singletons |
| 19 | +- **Before implementing ANYTHING NEW: Search the codebase** for similar patterns, reusable code, existing solutions |
| 20 | +- Consolidate and reuse before adding new functions or types |
| 21 | +- "Reinventing the wheel" is a code smell: investigate why it exists first |
| 22 | + |
| 23 | +#### Code Reuse Checklist |
| 24 | +1. Does a similar feature exist? Use it or extend it |
| 25 | +2. Can I reuse a helper function/extension/modifier? Do it |
| 26 | +3. Does a pattern already exist? Follow it exactly |
| 27 | +4. Am I duplicating logic? Refactor into a shared utility |
| 28 | +5. **Never implement features in isolation**: maximize consistency and minimize maintenance burden |
| 29 | + |
| 30 | +### Workflow |
| 31 | +- **NEVER merge PRs autonomously**: stop after creating, let user merge |
| 32 | + |
| 33 | +### SwiftUI API Parity (non-negotiable) |
| 34 | +Public APIs MUST match SwiftUI signatures exactly unless terminal constraints require deviation (document why in comments). |
| 35 | + |
| 36 | +| Aspect | Requirement | |
| 37 | +|--------|-------------| |
| 38 | +| Parameter names | Exact (`isPresented`, not `isVisible`) | |
| 39 | +| Parameter order | Exact (title, binding, actions, message) | |
| 40 | +| Parameter types | Match closely (ViewBuilder closures, not pre-built values) | |
| 41 | +| Trailing closures | `@ViewBuilder () -> T`, not `String` | |
| 42 | + |
| 43 | +**Before implementing:** Look up exact SwiftUI signature first. |
| 44 | +**TUI-specific APIs:** OK to add, but keep separate from SwiftUI equivalents. |
| 45 | + |
| 46 | +### View Architecture (non-negotiable) |
| 47 | + |
| 48 | +#### Public API: Every control is a View with a real body |
| 49 | + |
| 50 | +**The Rule:** |
| 51 | +- Every **public** control MUST be a `View` with a real `body: some View` |
| 52 | +- The `body` MUST return actual Views (not `Never`, not `fatalError()`) |
| 53 | +- All modifiers MUST propagate through the entire View hierarchy |
| 54 | +- Environment values MUST flow down automatically |
| 55 | + |
| 56 | +**Why this matters:** |
| 57 | +```swift |
| 58 | +// This MUST work exactly like SwiftUI: |
| 59 | +List("Items", selection: $selection) { |
| 60 | + ForEach(items) { item in |
| 61 | + Text(item.name) |
| 62 | + } |
| 63 | +} |
| 64 | +.foregroundColor(.red) // MUST affect all Text inside! |
| 65 | +.disabled(true) // MUST disable the entire List! |
| 66 | +``` |
| 67 | + |
| 68 | +#### Renderable: When and where it is allowed |
| 69 | + |
| 70 | +Terminal UI requires procedural buffer assembly (ANSI codes, Unicode borders, |
| 71 | +buffer overlays). `Renderable` is the mechanism for this. It is allowed in |
| 72 | +these cases: |
| 73 | + |
| 74 | +| Layer | Example | Renderable? | |
| 75 | +|-------|---------|-------------| |
| 76 | +| **Leaf nodes** | `Text`, `Spacer`, `Divider` | Yes (terminal primitives) | |
| 77 | +| **Private `_*Core` views** | `_ButtonCore`, `_VStackCore` | Yes (procedural ANSI rendering) | |
| 78 | +| **Layout primitives** | `_VStackCore`, `_HStackCore` | Yes + `Layoutable` (two-pass layout) | |
| 79 | +| **Modifier infrastructure** | `ModifiedView`, `EnvironmentModifier` | Yes (context/buffer pipeline) | |
| 80 | +| **Public controls** | `Button`, `VStack`, `List` | **No** (must use `body: some View`) | |
| 81 | + |
| 82 | +**The `_*Core` pattern:** |
| 83 | +```swift |
| 84 | +// Public View: real body, environment flows through |
| 85 | +public struct MyControl<Content: View>: View { |
| 86 | + let content: Content |
| 87 | + |
| 88 | + public var body: some View { |
| 89 | + _MyControlCore(content: content) |
| 90 | + } |
| 91 | +} |
| 92 | + |
| 93 | +// Private Core: Renderable for terminal-specific rendering |
| 94 | +private struct _MyControlCore<Content: View>: View, Renderable { |
| 95 | + let content: Content |
| 96 | + var body: Never { fatalError("_MyControlCore renders via Renderable") } |
| 97 | + |
| 98 | + func renderToBuffer(context: RenderContext) -> FrameBuffer { |
| 99 | + // Read environment from context, render with ANSI codes |
| 100 | + } |
| 101 | +} |
| 102 | +``` |
| 103 | + |
| 104 | +**Preferred: Pure composition (Box.swift is the reference):** |
| 105 | +```swift |
| 106 | +public struct MyControl<Content: View>: View { |
| 107 | + let content: Content |
| 108 | + |
| 109 | + public var body: some View { |
| 110 | + content |
| 111 | + .padding() |
| 112 | + .border() |
| 113 | + } |
| 114 | +} |
| 115 | +``` |
| 116 | + |
| 117 | +When possible, prefer composition over `_*Core`. Use `_*Core` + `Renderable` |
| 118 | +only when the rendering requires procedural buffer manipulation that cannot |
| 119 | +be expressed as View composition. |
| 120 | + |
| 121 | +**WRONG Pattern (public control with Renderable):** |
| 122 | +```swift |
| 123 | +public struct MyControl: View { |
| 124 | + public var body: Never { fatalError() } // WRONG! |
| 125 | +} |
| 126 | + |
| 127 | +extension MyControl: Renderable { // WRONG - public types must not be Renderable! |
| 128 | + func renderToBuffer() { ... } |
| 129 | +} |
| 130 | +``` |
| 131 | + |
| 132 | +**Before implementing ANY control:** |
| 133 | +1. Can it be composed from existing Views + modifiers? (preferred) |
| 134 | +2. If not, does the public View have a real `body` wrapping a private `_*Core`? |
| 135 | +3. Does `_*Core` read environment values from `RenderContext`? |
| 136 | +4. Test: `.foregroundColor()` on the control affects its content? |
| 137 | +5. Test: `.disabled()` on the control disables interactions? |
| 138 | + |
| 139 | +### Interactive Views: Focus & State (non-negotiable) |
| 140 | + |
| 141 | +All interactive views (Button, TextField, Toggle, Slider, etc.) that participate |
| 142 | +in the focus system MUST follow these rules: |
| 143 | + |
| 144 | +#### FocusID generation |
| 145 | +- Default focusIDs MUST use `context.identity.path`, never user-facing data |
| 146 | +- Pattern: `"\(prefix)-\(context.identity.path)"` (e.g. `"button-\(context.identity.path)"`) |
| 147 | +- Never use label text, titles, or other user content for focusIDs (collision risk) |
| 148 | + |
| 149 | +#### Focus registration |
| 150 | +- Use the shared `FocusRegistration` helper for all focus setup |
| 151 | +- Do NOT duplicate focus registration boilerplate in individual views |
| 152 | +- Registration, disabled-state check, and isFocused query are one operation |
| 153 | + |
| 154 | +#### StateStorage property indices |
| 155 | +- Every `_*Core` view MUST document its property indices with named constants: |
| 156 | +```swift |
| 157 | +private enum StateIndex { |
| 158 | + static let focusID = 0 |
| 159 | + static let handler = 1 |
| 160 | +} |
| 161 | +``` |
| 162 | +- Never use bare integer literals for `propertyIndex` |
| 163 | + |
| 164 | +#### Disabled state |
| 165 | +- Disabled views MUST NOT register with the focus system |
| 166 | +- Check `isDisabled` BEFORE calling `focusManager.register()` |
| 167 | +- Disabled styling MUST be visually consistent across all interactive views |
| 168 | + |
| 169 | +### SwiftUI API Design (non-negotiable) |
| 170 | + |
| 171 | +#### Init signatures: Keep them minimal |
| 172 | +- Public inits MUST match SwiftUI parameter names and order |
| 173 | +- TUI-specific options (focusID, emptyPlaceholder, etc.) MUST be modifiers, not init params |
| 174 | +- Minimize init overloads; prefer `@ViewBuilder` label variants over String convenience inits |
| 175 | + |
| 176 | +**Correct:** |
| 177 | +```swift |
| 178 | +List(selection: $selection) { content } |
| 179 | + .focusID("my-list") |
| 180 | + .listEmptyPlaceholder("No items") |
| 181 | +``` |
| 182 | + |
| 183 | +**Wrong:** |
| 184 | +```swift |
| 185 | +List(selection: $selection, focusID: "my-list", emptyPlaceholder: "No items") { content } |
| 186 | +``` |
| 187 | + |
| 188 | +#### Modifier-first principle |
| 189 | +TUI-specific behavior that SwiftUI handles via modifiers MUST also be modifiers: |
| 190 | +- Focus identity: `.focusID(_:)` |
| 191 | +- Placeholder text: `.listEmptyPlaceholder(_:)` |
| 192 | +- Visual customization: `.trackStyle(_:)`, `.buttonStyle(_:)`, etc. |
| 193 | + |
| 194 | +### File Organization |
| 195 | + |
| 196 | +- Source files SHOULD stay under 500 lines |
| 197 | +- If a file exceeds 500 lines, consider splitting: public API in one file, `_*Core` in another |
| 198 | +- One view per file (do not combine VStack + HStack + ZStack in one file) |
0 commit comments