docs: add Spec B (new input) and Spec C (platform & events) designs

Spec B: keybinding + browser packages, window file drop enhancement.
Spec C: dock/badge + lifecycle event packages.
Both reviewed — fixes applied for safe type assertions, EventType
constants, platform mapping table, service logic clarity, JSON tags.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-03-13 14:28:35 +00:00
parent bba743d2cb
commit bf685aa8d3
2 changed files with 536 additions and 0 deletions

View file

@ -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

View file

@ -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