docs: add Spec D (context menus & final cleanup) design
pkg/contextmenu wraps the last Wails v3 manager API. Display cleanup removes stale interfaces.go wrappers, migrates remaining direct s.app.* calls to IPC. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
7ea92419ae
commit
8133e367a7
1 changed files with 305 additions and 0 deletions
305
docs/superpowers/specs/2026-03-13-gui-final-cleanup-design.md
Normal file
305
docs/superpowers/specs/2026-03-13-gui-final-cleanup-design.md
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
# CoreGUI Spec D: Context Menus & Final Cleanup
|
||||
|
||||
**Date:** 2026-03-13
|
||||
**Status:** Approved
|
||||
**Scope:** Add context menu package as core.Service, remove stale Wails wrappers from display orchestrator
|
||||
|
||||
## Context
|
||||
|
||||
Specs A–C extracted 12 packages covering 10 of the 11 Wails v3 Manager APIs. Two gaps remain:
|
||||
|
||||
1. **`app.ContextMenu`** — the only Wails v3 manager without a core.Service wrapper
|
||||
2. **Stale `interfaces.go`** — display orchestrator still holds `wailsDialogManager`, `wailsEnvManager`, `wailsEventManager` wrappers and 4 direct `s.app.*` calls that bypass IPC
|
||||
|
||||
This spec closes both gaps, achieving full Wails v3 Manager API coverage through the IPC bus.
|
||||
|
||||
## Architecture
|
||||
|
||||
### New Package
|
||||
|
||||
| Package | Platform methods | IPC pattern |
|
||||
|---------|-----------------|-------------|
|
||||
| `pkg/contextmenu` | `Add()`, `Remove()`, `Get()`, `GetAll()` | Tasks (register/remove), Query (get/list), Action (item clicked) |
|
||||
|
||||
### Display Cleanup
|
||||
|
||||
Remove the `App` interface's `Dialog()`, `Env()`, `Event()` methods and their Wails adapter implementations. Migrate the 4 remaining direct calls to use IPC. The `App` interface reduces to `Quit()` and `Logger()` only.
|
||||
|
||||
---
|
||||
|
||||
## Package Design
|
||||
|
||||
### 1. pkg/contextmenu
|
||||
|
||||
**Platform interface:**
|
||||
|
||||
```go
|
||||
type Platform interface {
|
||||
Add(name string, menu ContextMenuDef) error
|
||||
Remove(name string) error
|
||||
Get(name string) (*ContextMenuDef, bool)
|
||||
GetAll() map[string]ContextMenuDef
|
||||
}
|
||||
```
|
||||
|
||||
**Our own types:**
|
||||
|
||||
```go
|
||||
type ContextMenuDef struct {
|
||||
Name string `json:"name"`
|
||||
Items []MenuItemDef `json:"items"`
|
||||
}
|
||||
|
||||
type MenuItemDef struct {
|
||||
Label string `json:"label"`
|
||||
Type string `json:"type,omitempty"` // "" (normal), "separator", "checkbox", "radio", "submenu"
|
||||
Accelerator string `json:"accelerator,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"` // nil = true (default)
|
||||
Checked bool `json:"checked,omitempty"`
|
||||
ActionID string `json:"actionId,omitempty"` // identifies which item was clicked
|
||||
Items []MenuItemDef `json:"items,omitempty"` // submenu children
|
||||
}
|
||||
```
|
||||
|
||||
**IPC messages:**
|
||||
|
||||
```go
|
||||
// Queries
|
||||
type QueryGet struct{ Name string } // → *ContextMenuDef (nil if not found)
|
||||
type QueryList struct{} // → map[string]ContextMenuDef
|
||||
|
||||
// Tasks
|
||||
type TaskAdd struct {
|
||||
Name string `json:"name"`
|
||||
Menu ContextMenuDef `json:"menu"`
|
||||
} // → error
|
||||
|
||||
type TaskRemove struct {
|
||||
Name string `json:"name"`
|
||||
} // → error
|
||||
|
||||
// Actions
|
||||
type ActionItemClicked struct {
|
||||
MenuName string `json:"menuName"`
|
||||
ActionID string `json:"actionId"`
|
||||
Data string `json:"data,omitempty"` // from --custom-contextmenu-data CSS
|
||||
}
|
||||
```
|
||||
|
||||
**Service logic:** The Service maintains a `map[string]ContextMenuDef` registry (mirroring the Wails `contextMenus` map). `TaskAdd` calls `platform.Add(name, menu)` — the Wails adapter translates `ContextMenuDef` to `*application.ContextMenu` with `OnClick` callbacks that broadcast `ActionItemClicked` via `s.Core().ACTION()`. `TaskRemove` calls `platform.Remove()` and deletes from registry. `QueryGet` and `QueryList` read from registry.
|
||||
|
||||
**Callback bridging:** The Wails adapter's `OnClick` handler receives `*application.Context` which provides `ContextMenuData()`. The adapter maps each `MenuItemDef.ActionID` to a callback that broadcasts `ActionItemClicked{MenuName, ActionID, ctx.ContextMenuData()}`.
|
||||
|
||||
**Wails adapter:** Wraps `app.ContextMenu.Add()`, `app.ContextMenu.Remove()`, `app.ContextMenu.Get()`, `app.ContextMenu.GetAll()`. Translates between our `ContextMenuDef`/`MenuItemDef` types and Wails `*ContextMenu`/`*MenuItem`.
|
||||
|
||||
**Config:** None — stateless.
|
||||
|
||||
**WS bridge events:** `contextmenu.item-clicked` (broadcast with menuName, actionId, data). TS apps call `contextmenu:add`, `contextmenu:remove`, `contextmenu:get`, `contextmenu:list` via WS→IPC.
|
||||
|
||||
---
|
||||
|
||||
## Display Orchestrator Changes
|
||||
|
||||
### New HandleIPCEvents Case
|
||||
|
||||
```go
|
||||
case contextmenu.ActionItemClicked:
|
||||
if s.events != nil {
|
||||
s.events.Emit(Event{Type: EventContextMenuClick,
|
||||
Data: map[string]any{
|
||||
"menuName": m.MenuName,
|
||||
"actionId": m.ActionID,
|
||||
"data": m.Data,
|
||||
}})
|
||||
}
|
||||
```
|
||||
|
||||
### New EventType Constant
|
||||
|
||||
```go
|
||||
EventContextMenuClick EventType = "contextmenu.item-clicked"
|
||||
```
|
||||
|
||||
### New WS→IPC Cases
|
||||
|
||||
```go
|
||||
case "contextmenu:add":
|
||||
name, _ := msg.Data["name"].(string)
|
||||
menuJSON, _ := json.Marshal(msg.Data["menu"])
|
||||
var menuDef contextmenu.ContextMenuDef
|
||||
_ = json.Unmarshal(menuJSON, &menuDef)
|
||||
result, handled, err = s.Core().PERFORM(contextmenu.TaskAdd{
|
||||
Name: name, Menu: menuDef,
|
||||
})
|
||||
case "contextmenu:remove":
|
||||
name, _ := msg.Data["name"].(string)
|
||||
result, handled, err = s.Core().PERFORM(contextmenu.TaskRemove{Name: name})
|
||||
case "contextmenu:get":
|
||||
name, _ := msg.Data["name"].(string)
|
||||
result, handled, err = s.Core().QUERY(contextmenu.QueryGet{Name: name})
|
||||
case "contextmenu:list":
|
||||
result, handled, err = s.Core().QUERY(contextmenu.QueryList{})
|
||||
```
|
||||
|
||||
### IDE Command Migration
|
||||
|
||||
Replace direct `s.app.Event().Emit()` calls with IPC Actions:
|
||||
|
||||
```go
|
||||
// New Action in pkg/display/messages.go
|
||||
type ActionIDECommand struct {
|
||||
Command string `json:"command"` // "save", "run", "build"
|
||||
}
|
||||
```
|
||||
|
||||
```go
|
||||
// Before (stale):
|
||||
func (s *Service) handleSaveFile() { s.app.Event().Emit("ide:save") }
|
||||
func (s *Service) handleRun() { s.app.Event().Emit("ide:run") }
|
||||
func (s *Service) handleBuild() { s.app.Event().Emit("ide:build") }
|
||||
|
||||
// After (IPC):
|
||||
func (s *Service) handleSaveFile() { _ = s.Core().ACTION(ActionIDECommand{Command: "save"}) }
|
||||
func (s *Service) handleRun() { _ = s.Core().ACTION(ActionIDECommand{Command: "run"}) }
|
||||
func (s *Service) handleBuild() { _ = s.Core().ACTION(ActionIDECommand{Command: "build"}) }
|
||||
```
|
||||
|
||||
The `HandleIPCEvents` bridges these to WS:
|
||||
|
||||
```go
|
||||
case ActionIDECommand:
|
||||
if s.events != nil {
|
||||
s.events.Emit(Event{Type: EventIDECommand,
|
||||
Data: map[string]any{"command": m.Command}})
|
||||
}
|
||||
```
|
||||
|
||||
### handleOpenFile Migration
|
||||
|
||||
```go
|
||||
// Before (stale — uses s.app.Dialog() directly):
|
||||
func (s *Service) handleOpenFile() {
|
||||
dialog := s.app.Dialog().OpenFile()
|
||||
dialog.SetTitle("Open File")
|
||||
// ...
|
||||
}
|
||||
|
||||
// After (IPC):
|
||||
func (s *Service) handleOpenFile() {
|
||||
result, handled, err := s.Core().PERFORM(dialog.TaskOpenFile{
|
||||
Opts: dialog.OpenFileOptions{
|
||||
Title: "Open File",
|
||||
AllowMultiple: false,
|
||||
},
|
||||
})
|
||||
if err != nil || !handled {
|
||||
return
|
||||
}
|
||||
paths, ok := result.([]string)
|
||||
if !ok || len(paths) == 0 {
|
||||
return
|
||||
}
|
||||
_, _, _ = s.Core().PERFORM(window.TaskOpenWindow{
|
||||
Opts: []window.WindowOption{
|
||||
window.WithName("editor"),
|
||||
window.WithTitle(paths[0] + " - Editor"),
|
||||
window.WithURL("/#/developer/editor?file=" + paths[0]),
|
||||
window.WithSize(1200, 800),
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### Removed Types
|
||||
|
||||
After migration, remove from `interfaces.go`:
|
||||
- `DialogManager` interface
|
||||
- `EnvManager` interface
|
||||
- `EventManager` interface
|
||||
- `wailsDialogManager` struct + methods
|
||||
- `wailsEnvManager` struct + methods
|
||||
- `wailsEventManager` struct + methods
|
||||
- `App.Dialog()`, `App.Env()`, `App.Event()` methods from interface
|
||||
|
||||
The `App` interface reduces to:
|
||||
|
||||
```go
|
||||
type App interface {
|
||||
Logger() Logger
|
||||
Quit()
|
||||
}
|
||||
```
|
||||
|
||||
## Dependency Direction
|
||||
|
||||
```
|
||||
pkg/display (orchestrator)
|
||||
├── imports pkg/contextmenu (message types only)
|
||||
├── imports pkg/dialog (message types — for handleOpenFile migration)
|
||||
├── ... existing imports ...
|
||||
|
||||
pkg/contextmenu (independent)
|
||||
├── imports core/go (DI, IPC)
|
||||
└── uses Platform interface
|
||||
```
|
||||
|
||||
No circular dependencies.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
```go
|
||||
func TestContextMenu_AddAndClick_Good(t *testing.T) {
|
||||
mock := &mockPlatform{}
|
||||
c, _ := core.New(
|
||||
core.WithService(contextmenu.Register(mock)),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
c.ServiceStartup(context.Background(), nil)
|
||||
|
||||
_, handled, err := c.PERFORM(contextmenu.TaskAdd{
|
||||
Name: "file-menu",
|
||||
Menu: contextmenu.ContextMenuDef{
|
||||
Name: "file-menu",
|
||||
Items: []contextmenu.MenuItemDef{
|
||||
{Label: "Open", ActionID: "open"},
|
||||
{Label: "Delete", ActionID: "delete"},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
// Simulate click via mock
|
||||
mock.simulateClick("file-menu", "open", "file-123")
|
||||
|
||||
// Verify action was broadcast
|
||||
}
|
||||
|
||||
func TestContextMenu_Remove_Good(t *testing.T) {
|
||||
mock := &mockPlatform{}
|
||||
c, _ := core.New(
|
||||
core.WithService(contextmenu.Register(mock)),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
c.ServiceStartup(context.Background(), nil)
|
||||
|
||||
// Add then remove
|
||||
_, _, _ = c.PERFORM(contextmenu.TaskAdd{Name: "test", Menu: contextmenu.ContextMenuDef{Name: "test"}})
|
||||
_, handled, err := c.PERFORM(contextmenu.TaskRemove{Name: "test"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
// Verify removed
|
||||
result, _, _ := c.QUERY(contextmenu.QueryGet{Name: "test"})
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
```
|
||||
|
||||
## Deferred Work
|
||||
|
||||
- **Dynamic menu updates**: `TaskUpdate` to modify items on an existing menu (SetEnabled, SetLabel, SetChecked). Currently requires remove + re-add.
|
||||
- **Per-window context menus**: Context menus are global — scoping to specific windows would require window ID in the registration.
|
||||
|
||||
## Licence
|
||||
|
||||
EUPL-1.2
|
||||
Loading…
Add table
Reference in a new issue