diff --git a/docs/superpowers/specs/2026-03-13-gui-new-input-design.md b/docs/superpowers/specs/2026-03-13-gui-new-input-design.md new file mode 100644 index 0000000..250d032 --- /dev/null +++ b/docs/superpowers/specs/2026-03-13-gui-new-input-design.md @@ -0,0 +1,242 @@ +# CoreGUI Spec B: New Input + +**Date:** 2026-03-13 +**Status:** Approved +**Scope:** Add keybinding and browser packages as core.Services, enhance window service with file drop events + +## Context + +Spec A extracted 5 packages from display. This spec adds input capabilities not yet present in core/gui: keyboard shortcuts, browser delegation, and file drop events. Each follows the three-layer pattern (IPC Bus → Service → Platform Interface). + +## Architecture + +### New Packages + +| Package | Platform methods | IPC pattern | +|---------|-----------------|-------------| +| `pkg/keybinding` | `Add()`, `Remove()`, `GetAll()` | Task add/remove, Query list, Action triggered | +| `pkg/browser` | `OpenURL()`, `OpenFile()` | Tasks (side-effects) | + +### Window Service Enhancement + +File drop is not a separate package — it extends `pkg/window` with an `OnFileDrop` callback on `PlatformWindow` and a new `ActionFilesDropped` broadcast. + +--- + +## Package Designs + +### 1. pkg/keybinding + +**Platform interface:** + +```go +type Platform interface { + Add(accelerator string, handler func()) error + Remove(accelerator string) error + GetAll() []string +} +``` + +Platform-aware accelerator syntax: `Cmd+S` (macOS), `Ctrl+S` (Windows/Linux). Special keys: `F1-F12`, `Escape`, `Enter`, `Space`, `Tab`, `Backspace`, `Delete`, arrow keys. + +**Our own types:** + +```go +type BindingInfo struct { + Accelerator string `json:"accelerator"` + Description string `json:"description"` +} +``` + +**IPC messages:** + +```go +// Queries +type QueryList struct{} // → []BindingInfo + +// Tasks +type TaskAdd struct { + Accelerator string `json:"accelerator"` + Description string `json:"description"` +} // → error + +type TaskRemove struct { + Accelerator string `json:"accelerator"` +} // → error + +// Actions +type ActionTriggered struct { + Accelerator string `json:"accelerator"` +} +``` + +**Service logic:** The Service maintains a `map[string]BindingInfo` registry. When `TaskAdd` is received, it returns `ErrAlreadyRegistered` if the accelerator exists (callers must `TaskRemove` first to rebind). Otherwise, the Service calls `platform.Add(accelerator, callback)` where the callback broadcasts `ActionTriggered` via `s.Core().ACTION()`. `QueryList` reads from the in-memory registry (not `platform.GetAll()`) — `platform.GetAll()` is for adapter-level reconciliation only. The display orchestrator bridges `ActionTriggered` → `Event{Type: "keybinding.triggered"}` for WS clients. + +**Wails adapter:** Wraps `app.KeyBinding.Add()`, `app.KeyBinding.Remove()`, `app.KeyBinding.GetAll()`. + +**Config:** None — bindings are registered programmatically. + +**WS bridge events:** `keybinding.triggered` (broadcast). TS apps call `keybinding:add`, `keybinding:remove`, `keybinding:list` via WS→IPC. + +--- + +### 2. pkg/browser + +**Platform interface:** + +```go +type Platform interface { + OpenURL(url string) error + OpenFile(path string) error +} +``` + +**IPC messages (all Tasks):** + +```go +type TaskOpenURL struct{ URL string `json:"url"` } // → error +type TaskOpenFile struct{ Path string `json:"path"` } // → error +``` + +**Wails adapter:** Wraps `app.Browser.OpenURL()` and `app.Browser.OpenFile()`. + +**Config:** None — stateless. + +**WS bridge:** No actions. TS apps call `browser:open-url`, `browser:open-file` via WS→IPC Tasks. + +--- + +### 3. Window Service Enhancement — File Drop + +**PlatformWindow interface addition:** + +```go +// Added to existing PlatformWindow interface in pkg/window/platform.go +OnFileDrop(handler func(paths []string, targetID string)) +``` + +**New Action in pkg/window/messages.go:** + +```go +type ActionFilesDropped struct { + Name string `json:"name"` // window name + Paths []string `json:"paths"` + TargetID string `json:"targetId,omitempty"` +} +``` + +**Service wiring:** The existing `trackWindow()` method in `pkg/window/service.go` gains a call to `pw.OnFileDrop()` that broadcasts `ActionFilesDropped`. Adding `OnFileDrop` to `PlatformWindow` is a breaking interface change — `MockWindow` in `mock_platform.go` and the Wails adapter must both gain no-op stubs. + +**WS bridge:** Display orchestrator adds a case for `window.ActionFilesDropped` → `Event{Type: "window.filedrop"}`. + +**Note:** File drop is opt-in per window via the existing `EnableFileDrop` field in `PlatformWindowOptions`. The HTML target element uses `data-file-drop-target` attribute (Wails v3 convention). HTML5 internal drag-and-drop is purely frontend (JS/CSS) — no Go package needed. + +--- + +## Display Orchestrator Changes + +### New HandleIPCEvents Cases + +```go +case keybinding.ActionTriggered: + if s.events != nil { + s.events.Emit(Event{Type: "keybinding.triggered", + Data: map[string]any{"accelerator": m.Accelerator}}) + } +case window.ActionFilesDropped: + if s.events != nil { + s.events.Emit(Event{Type: "window.filedrop", Window: m.Name, + Data: map[string]any{"paths": m.Paths, "targetId": m.TargetID}}) + } +``` + +### New WS→IPC Cases + +```go +case "keybinding:add": + accelerator, _ := msg.Data["accelerator"].(string) + description, _ := msg.Data["description"].(string) + result, handled, err = s.Core().PERFORM(keybinding.TaskAdd{ + Accelerator: accelerator, Description: description, + }) +case "keybinding:remove": + accelerator, _ := msg.Data["accelerator"].(string) + result, handled, err = s.Core().PERFORM(keybinding.TaskRemove{ + Accelerator: accelerator, + }) +case "keybinding:list": + result, handled, err = s.Core().QUERY(keybinding.QueryList{}) +case "browser:open-url": + url, _ := msg.Data["url"].(string) + result, handled, err = s.Core().PERFORM(browser.TaskOpenURL{URL: url}) +case "browser:open-file": + path, _ := msg.Data["path"].(string) + result, handled, err = s.Core().PERFORM(browser.TaskOpenFile{Path: path}) +``` + +## Dependency Direction + +``` +pkg/display (orchestrator) +├── imports pkg/keybinding (message types only) +├── imports pkg/browser (message types only) +└── ... existing imports ... + +pkg/keybinding (independent) +├── imports core/go (DI, IPC) +└── uses Platform interface + +pkg/browser (independent) +├── imports core/go (DI, IPC) +└── uses Platform interface +``` + +No circular dependencies. + +## Testing Strategy + +```go +func TestKeybinding_AddAndTrigger_Good(t *testing.T) { + mock := &mockPlatform{} + c, _ := core.New( + core.WithService(keybinding.Register(mock)), + core.WithServiceLock(), + ) + c.ServiceStartup(context.Background(), nil) + + _, handled, err := c.PERFORM(keybinding.TaskAdd{ + Accelerator: "Ctrl+S", Description: "Save", + }) + require.NoError(t, err) + assert.True(t, handled) + + // Simulate shortcut trigger via mock + mock.trigger("Ctrl+S") + + // Verify action was broadcast (captured via registered handler) +} + +func TestBrowser_OpenURL_Good(t *testing.T) { + mock := &mockPlatform{} + c, _ := core.New( + core.WithService(browser.Register(mock)), + core.WithServiceLock(), + ) + c.ServiceStartup(context.Background(), nil) + + _, handled, err := c.PERFORM(browser.TaskOpenURL{URL: "https://example.com"}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, "https://example.com", mock.lastURL) +} +``` + +## Deferred Work + +- **Per-window keybindings**: Currently global only. Per-window scoping requires handler-receives-window pattern. +- **Browser fallback**: Copy URL to clipboard when browser open fails. +- **HTML5 drag-drop helpers**: TS SDK utilities for internal drag-drop — purely frontend. + +## Licence + +EUPL-1.2 diff --git a/docs/superpowers/specs/2026-03-13-gui-platform-events-design.md b/docs/superpowers/specs/2026-03-13-gui-platform-events-design.md new file mode 100644 index 0000000..8b41288 --- /dev/null +++ b/docs/superpowers/specs/2026-03-13-gui-platform-events-design.md @@ -0,0 +1,294 @@ +# CoreGUI Spec C: Platform & Events + +**Date:** 2026-03-13 +**Status:** Approved +**Scope:** Add dock/badge and application lifecycle event packages as core.Services + +## Context + +Spec A extracted display features, Spec B added input capabilities. This spec covers platform-specific features (dock icon, badge) and application lifecycle events — the remaining Wails v3 features needed for full IPC coverage. + +Cursor management is excluded — CSS `cursor:` property handles it in the webview without Go involvement. + +## Architecture + +### New Packages + +| Package | Platform methods | IPC pattern | +|---------|-----------------|-------------| +| `pkg/dock` | `ShowIcon()`, `HideIcon()`, `SetBadge()`, `RemoveBadge()` | Tasks (mutations), Query visibility | +| `pkg/lifecycle` | `OnApplicationEvent()` | Actions broadcast for each event type | + +--- + +## Package Designs + +### 1. pkg/dock + +**Platform interface:** + +```go +type Platform interface { + ShowIcon() error + HideIcon() error + SetBadge(label string) error + RemoveBadge() error + IsVisible() bool +} +``` + +macOS: dock icon show/hide + badge. Windows: taskbar badge only (show/hide not supported). Linux: not supported — adapter returns nil. Platform adapter returns nil on unsupported OS. + +**IPC messages:** + +```go +// Queries +type QueryVisible struct{} // → bool + +// Tasks +type TaskShowIcon struct{} // → error +type TaskHideIcon struct{} // → error +type TaskSetBadge struct{ Label string } // → error +type TaskRemoveBadge struct{} // → error + +// Actions +type ActionVisibilityChanged struct{ Visible bool } +``` + +**Badge conventions:** +- Empty string `""`: default system badge indicator +- Numeric `"3"`, `"99"`: unread count +- Text `"New"`, `"Paused"`: brief status labels + +**Wails adapter:** Wraps `app.Dock.HideAppIcon()`, `app.Dock.ShowAppIcon()` (macOS only), and badge APIs. + +**Service logic:** After a successful `TaskShowIcon`, the Service broadcasts `ActionVisibilityChanged{Visible: true}`. After `TaskHideIcon`, broadcasts `ActionVisibilityChanged{Visible: false}`. `QueryVisible` delegates to `platform.IsVisible()`. + +**Config:** None — stateless. No config section required in display orchestrator. + +**WS bridge events:** `dock.visibility-changed` (on show/hide). TS apps call `dock:show`, `dock:hide`, `dock:badge`, `dock:badge-remove`, `dock:visible` via WS→IPC. + +--- + +### 2. pkg/lifecycle + +**Platform interface:** + +```go +type Platform interface { + // OnApplicationEvent registers a handler for a fire-and-forget event type. + // Events that carry data (e.g. file path) use dedicated methods. + OnApplicationEvent(eventType EventType, handler func()) func() // returns cancel + OnOpenedWithFile(handler func(path string)) func() // returns cancel +} +``` + +Separate method for file-open because it carries data (the file path). + +**Our own types:** + +```go +type EventType int + +const ( + EventApplicationStarted EventType = iota + EventWillTerminate + EventDidBecomeActive + EventDidResignActive + EventPowerStatusChanged // Windows: APMPowerStatusChange + EventSystemSuspend // Windows: APMSuspend + EventSystemResume // Windows: APMResume +) +``` + +**IPC messages (all Actions — lifecycle events are broadcasts):** + +```go +type ActionApplicationStarted struct{} +type ActionOpenedWithFile struct{ Path string } +type ActionWillTerminate struct{} +type ActionDidBecomeActive struct{} +type ActionDidResignActive struct{} +type ActionPowerStatusChanged struct{} +type ActionSystemSuspend struct{} +type ActionSystemResume struct{} +``` + +**Service logic:** During `OnStartup`, the Service registers a platform callback for each `EventType` and for file-open. Each callback broadcasts the corresponding Action via `s.Core().ACTION()`. `OnShutdown` cancels all registrations. + +**Wails adapter:** Wraps `app.Event.OnApplicationEvent()` for each Wails event type: + +| Our EventType | Wails event | Platforms | +|---|---|---| +| `EventApplicationStarted` | `events.Common.ApplicationStarted` | all | +| `EventWillTerminate` | `events.Mac.ApplicationWillTerminate` | macOS only | +| `EventDidBecomeActive` | `events.Mac.ApplicationDidBecomeActive` | macOS only | +| `EventDidResignActive` | `events.Mac.ApplicationDidResignActive` | macOS only | +| `EventPowerStatusChanged` | `events.Windows.APMPowerStatusChange` | Windows only | +| `EventSystemSuspend` | `events.Windows.APMSuspend` | Windows only | +| `EventSystemResume` | `events.Windows.APMResume` | Windows only | + +Platform-specific events no-op silently on unsupported OS (adapter registers nothing). + +**Note:** `ActionApplicationStarted` maps to the Wails `ApplicationStarted` event, which fires when the platform application starts. This is distinct from `core.ActionServiceStartup`, which fires after all core.Services complete `OnStartup`. TS clients should use `app.started` for platform-level readiness and subscribe to services individually for service-level readiness. + +**Config:** None — event-driven, no state. No config section required in display orchestrator. + +**WS bridge events:** + +| Action | WS Event Type | +|--------|--------------| +| `ActionApplicationStarted` | `app.started` | +| `ActionOpenedWithFile` | `app.opened-with-file` | +| `ActionWillTerminate` | `app.will-terminate` | +| `ActionDidBecomeActive` | `app.active` | +| `ActionDidResignActive` | `app.inactive` | +| `ActionPowerStatusChanged` | `system.power-change` | +| `ActionSystemSuspend` | `system.suspend` | +| `ActionSystemResume` | `system.resume` | + +--- + +## Display Orchestrator Changes + +### New EventType Constants (in `pkg/display/events.go`) + +```go +EventDockVisibility EventType = "dock.visibility-changed" +EventAppStarted EventType = "app.started" +EventAppOpenedWithFile EventType = "app.opened-with-file" +EventAppWillTerminate EventType = "app.will-terminate" +EventAppActive EventType = "app.active" +EventAppInactive EventType = "app.inactive" +EventSystemPowerChange EventType = "system.power-change" +EventSystemSuspend EventType = "system.suspend" +EventSystemResume EventType = "system.resume" +``` + +### New HandleIPCEvents Cases + +```go +case dock.ActionVisibilityChanged: + if s.events != nil { + s.events.Emit(Event{Type: EventDockVisibility, + Data: map[string]any{"visible": m.Visible}}) + } +case lifecycle.ActionApplicationStarted: + if s.events != nil { + s.events.Emit(Event{Type: EventAppStarted}) + } +case lifecycle.ActionOpenedWithFile: + if s.events != nil { + s.events.Emit(Event{Type: EventAppOpenedWithFile, + Data: map[string]any{"path": m.Path}}) + } +case lifecycle.ActionWillTerminate: + if s.events != nil { + s.events.Emit(Event{Type: EventAppWillTerminate}) + } +case lifecycle.ActionDidBecomeActive: + if s.events != nil { + s.events.Emit(Event{Type: EventAppActive}) + } +case lifecycle.ActionDidResignActive: + if s.events != nil { + s.events.Emit(Event{Type: EventAppInactive}) + } +case lifecycle.ActionPowerStatusChanged: + if s.events != nil { + s.events.Emit(Event{Type: EventSystemPowerChange}) + } +case lifecycle.ActionSystemSuspend: + if s.events != nil { + s.events.Emit(Event{Type: EventSystemSuspend}) + } +case lifecycle.ActionSystemResume: + if s.events != nil { + s.events.Emit(Event{Type: EventSystemResume}) + } +``` + +### New WS→IPC Cases + +```go +case "dock:show": + result, handled, err = s.Core().PERFORM(dock.TaskShowIcon{}) +case "dock:hide": + result, handled, err = s.Core().PERFORM(dock.TaskHideIcon{}) +case "dock:badge": + label, _ := msg.Data["label"].(string) + result, handled, err = s.Core().PERFORM(dock.TaskSetBadge{Label: label}) +case "dock:badge-remove": + result, handled, err = s.Core().PERFORM(dock.TaskRemoveBadge{}) +case "dock:visible": + result, handled, err = s.Core().QUERY(dock.QueryVisible{}) +``` + +Lifecycle events are outbound-only (Actions → WS). No inbound WS→IPC needed. + +## Dependency Direction + +``` +pkg/display (orchestrator) +├── imports pkg/dock (message types only) +├── imports pkg/lifecycle (message types only) +└── ... existing imports ... + +pkg/dock (independent) +├── imports core/go (DI, IPC) +└── uses Platform interface + +pkg/lifecycle (independent) +├── imports core/go (DI, IPC) +└── uses Platform interface +``` + +No circular dependencies. + +## Testing Strategy + +```go +func TestDock_SetBadge_Good(t *testing.T) { + mock := &mockPlatform{visible: true} + c, _ := core.New( + core.WithService(dock.Register(mock)), + core.WithServiceLock(), + ) + c.ServiceStartup(context.Background(), nil) + + _, handled, err := c.PERFORM(dock.TaskSetBadge{Label: "3"}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, "3", mock.lastBadge) +} + +func TestLifecycle_BecomeActive_Good(t *testing.T) { + mock := &mockPlatform{} + c, _ := core.New( + core.WithService(lifecycle.Register(mock)), + core.WithServiceLock(), + ) + c.ServiceStartup(context.Background(), nil) + + var received bool + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if _, ok := msg.(lifecycle.ActionDidBecomeActive); ok { + received = true + } + return nil + }) + + mock.simulateEvent(lifecycle.EventDidBecomeActive) + assert.True(t, received) +} +``` + +## Deferred Work + +- **Window close hooks**: Cancellable `OnClose` (return false to prevent). Requires hook vs listener distinction in the window service — follow-up enhancement. +- **Custom badge styling**: Windows-specific font/colour options for `SetCustomBadge`. macOS uses system styling only. +- **Notification centre integration**: Deep integration with macOS/Windows notification centres beyond simple badge. + +## Licence + +EUPL-1.2