[agent/codex:gpt-5.4-mini] Update the code against the AX design principles in ~/spec/r... #5

Open
Virgil wants to merge 18 commits from main into dev
84 changed files with 3458 additions and 1987 deletions

View file

@ -46,7 +46,7 @@ All Wails application APIs are abstracted behind interfaces in `interfaces.go` (
| `window.go` | `WindowOption` functional options pattern, `Window` type alias for `application.WebviewWindowOptions` |
| `window_state.go` | `WindowStateManager` — persists window position/size across restarts |
| `layout.go` | `LayoutManager` — save/restore named window arrangements |
| `events.go` | `WSEventManager` — WebSocket pub/sub for window/theme/screen events |
| `events.go` | `WebSocketEventManager` — WebSocket pub/sub for window/theme/screen events |
| `interfaces.go` | Abstract interfaces + Wails adapter implementations |
| `actions.go` | `ActionOpenWindow` IPC message type |
| `menu.go` | Application menu construction |
@ -61,7 +61,7 @@ All Wails application APIs are abstracted behind interfaces in `interfaces.go` (
- **Functional options**: `WindowOption` functions (`WindowName()`, `WindowTitle()`, `WindowWidth()`, etc.) configure `application.WebviewWindowOptions`
- **Type alias**: `Window = application.WebviewWindowOptions` — direct alias, not a wrapper
- **Event broadcasting**: `WSEventManager` uses gorilla/websocket with a buffered channel (`eventBuffer`) and per-client subscription filtering (supports `"*"` wildcard)
- **Event broadcasting**: `WebSocketEventManager` uses gorilla/websocket with a buffered channel (`eventBuffer`) and per-client subscription filtering (supports `"*"` wildcard)
- **Window lookup by name**: Most Service methods iterate `s.app.Window().GetAll()` and type-assert to `*application.WebviewWindow`, then match by `Name()`
## Testing

View file

@ -5,7 +5,7 @@ Complete API reference for the Display service (`pkg/display`).
## Service Creation
```go
func NewService(c *core.Core) (any, error)
func NewService() *Service
```
## Window Management
@ -46,23 +46,23 @@ Returns:
```go
type WindowInfo struct {
Name string
Title string
X int
Y int
Width int
Height int
IsVisible bool
IsFocused bool
IsMaximized bool
IsMinimized bool
Name string
Title string
X int
Y int
Width int
Height int
Visible bool
Minimized bool
Maximized bool
Focused bool
}
```
### ListWindowInfos
```go
func (s *Service) ListWindowInfos() []*WindowInfo
func (s *Service) ListWindowInfos() []WindowInfo
```
### Window Position & Size
@ -346,7 +346,7 @@ type Theme struct {
## Events
```go
func (s *Service) GetEventManager() *EventManager
func (s *Service) GetWebSocketEventManager() *WebSocketEventManager
```
The EventManager handles WebSocket connections for real-time events.
The WebSocketEventManager handles WebSocket connections for real-time events.

View file

@ -74,10 +74,9 @@ The help service can work standalone or integrated with Core:
### With Display Service
When Display service is available, help opens through the IPC action system:
When Display service is available, help opens through the display service's declarative window API:
```go
// Automatically uses display.open_window action
helpService.Init(core, displayService)
helpService.Show()
```
@ -134,19 +133,6 @@ The help window opens with default settings:
| Width | 800px |
| Height | 600px |
## IPC Action
## Display Integration
When using Display service, help triggers this action:
```go
{
"action": "display.open_window",
"name": "help",
"options": {
"Title": "Help",
"Width": 800,
"Height": 600,
"URL": "/#anchor", // When using ShowAt
},
}
```
The help window spec stays internal to the service. Callers initialize the help service with the display service and then call `Show()` or `ShowAt()`; the display layer opens the window from a declarative `window.Window` spec.

3
docs/ref/wails-v3/go.mod Normal file
View file

@ -0,0 +1,3 @@
module github.com/wailsapp/wails/v3
go 1.26.0

View file

@ -0,0 +1,9 @@
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Wails Assets Placeholder</title>
</head>
<body>
</body>
</html>

44
go.mod
View file

@ -5,73 +5,37 @@ go 1.26.0
require (
forge.lthn.ai/core/config v0.1.8
forge.lthn.ai/core/go v0.3.3
forge.lthn.ai/core/go-io v0.1.7
forge.lthn.ai/core/go-log v0.0.4
forge.lthn.ai/core/go-webview v0.1.7
github.com/gorilla/websocket v1.5.3
github.com/leaanthony/u v1.1.1
github.com/modelcontextprotocol/go-sdk v1.4.1
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
github.com/stretchr/testify v1.11.1
github.com/wailsapp/wails/v3 v3.0.0-alpha.74
)
replace github.com/wailsapp/wails/v3 => ./stubs/wails
require (
dario.cat/mergo v1.0.2 // indirect
forge.lthn.ai/core/go-io v0.1.7 // indirect
forge.lthn.ai/core/go-log v0.0.4 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.4.0 // indirect
github.com/adrg/xdg v0.5.3 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.8.0 // indirect
github.com/go-git/go-git/v5 v5.17.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect
github.com/kevinburke/ssh_config v1.6.0 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/lmittmann/tint v1.1.3 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/samber/lo v1.53.0 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/segmentio/encoding v0.5.4 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.21.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/wailsapp/go-webview2 v1.0.23 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/oauth2 v0.36.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

128
go.sum
View file

@ -1,5 +1,3 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
forge.lthn.ai/core/config v0.1.8 h1:xP2hys7T94QGVF/OTh84/Zr5Dm/dL/0vzjht8zi+LOg=
forge.lthn.ai/core/config v0.1.8/go.mod h1:8epZrkwoCt+5ayrqdinOUU/+w6UoxOyv9ZrdgVOgYfQ=
forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0=
@ -10,130 +8,40 @@ forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
forge.lthn.ai/core/go-webview v0.1.7 h1:9+aEHeAvNcPX8Zwr+UGu0/T+menRm5T1YOmqZ9dawDc=
forge.lthn.ai/core/go-webview v0.1.7/go.mod h1:5n1tECD1wBV/uFZRY9ZjfPFO5TYZrlaR3mQFwvO2nek=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.4.0 h1:Zq/pbM3F5DFgJiMouxEdSVY44MVoQNEKp5d5QxIQceQ=
github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0=
github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM=
github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I=
github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc=
github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM=
github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
@ -142,60 +50,24 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/wails/v3 v3.0.0-alpha.74 h1:wRm1EiDQtxDisXk46NtpiBH90STwfKp36NrTDwOEdxw=
github.com/wailsapp/wails/v3 v3.0.0-alpha.74/go.mod h1:4saK4A4K9970X+X7RkMwP2lyGbLogcUz54wVeq4C/V8=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA=
golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -1,10 +1,7 @@
// pkg/browser/register.go
package browser
import "forge.lthn.ai/core/go/pkg/core"
// Register creates a factory closure that captures the Platform adapter.
// The returned function has the signature WithService requires: func(*Core) (any, error).
func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
return &Service{

View file

@ -1,4 +1,3 @@
// pkg/browser/service.go
package browser
import (
@ -7,23 +6,18 @@ import (
"forge.lthn.ai/core/go/pkg/core"
)
// Options holds configuration for the browser service.
type Options struct{}
// Service is a core.Service that delegates browser/file-open operations
// to the platform. It is stateless — no queries, no actions.
type Service struct {
*core.ServiceRuntime[Options]
platform Platform
}
// OnStartup registers IPC handlers.
func (s *Service) OnStartup(ctx context.Context) error {
s.Core().RegisterTask(s.handleTask)
return nil
}
// HandleIPCEvents is auto-discovered and registered by core.WithService.
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}

View file

@ -1,42 +1,28 @@
// pkg/contextmenu/messages.go
package contextmenu
import "errors"
// ErrMenuNotFound is returned when attempting to remove or get a menu
// that does not exist in the registry.
var ErrMenuNotFound = errors.New("contextmenu: menu not found")
var ErrorMenuNotFound = errors.New("contextmenu: menu not found")
// --- Queries ---
// QueryGet returns a single context menu by name. Result: *ContextMenuDef (nil if not found)
type QueryGet struct {
Name string `json:"name"`
}
// QueryList returns all registered context menus. Result: map[string]ContextMenuDef
type QueryList struct{}
// --- Tasks ---
// TaskAdd registers a context menu. Result: nil
// If a menu with the same name already exists it is replaced (remove + re-add).
type TaskAdd struct {
Name string `json:"name"`
Menu ContextMenuDef `json:"menu"`
}
// TaskRemove unregisters a context menu. Result: nil
// Returns ErrMenuNotFound if the menu does not exist.
type TaskRemove struct {
Name string `json:"name"`
}
// --- Actions ---
// ActionItemClicked is broadcast when a context menu item is clicked.
// The Data field is populated from the CSS --custom-contextmenu-data property
// on the element that triggered the context menu.
type ActionItemClicked struct {
MenuName string `json:"menuName"`
ActionID string `json:"actionId"`

View file

@ -1,16 +1,13 @@
// pkg/contextmenu/register.go
package contextmenu
import "forge.lthn.ai/core/go/pkg/core"
// Register creates a factory closure that captures the Platform adapter.
// The returned function has the signature WithService requires: func(*Core) (any, error).
func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
return &Service{
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
platform: p,
menus: make(map[string]ContextMenuDef),
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
platform: p,
registeredMenus: make(map[string]ContextMenuDef),
}, nil
}
}

View file

@ -3,31 +3,25 @@ package contextmenu
import (
"context"
"fmt"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/go/pkg/core"
)
// Options holds configuration for the context menu service.
type Options struct{}
// Service is a core.Service managing context menus via IPC.
// It maintains an in-memory registry of menus (map[string]ContextMenuDef)
// and delegates platform-level registration to the Platform interface.
type Service struct {
*core.ServiceRuntime[Options]
platform Platform
menus map[string]ContextMenuDef
platform Platform
registeredMenus map[string]ContextMenuDef
}
// OnStartup registers IPC handlers.
func (s *Service) OnStartup(ctx context.Context) error {
s.Core().RegisterQuery(s.handleQuery)
s.Core().RegisterTask(s.handleTask)
return nil
}
// HandleIPCEvents is auto-discovered and registered by core.WithService.
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}
@ -45,19 +39,17 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
}
}
// queryGet returns a single menu definition by name, or nil if not found.
func (s *Service) queryGet(q QueryGet) *ContextMenuDef {
menu, ok := s.menus[q.Name]
menu, ok := s.registeredMenus[q.Name]
if !ok {
return nil
}
return &menu
}
// queryList returns a copy of all registered menus.
func (s *Service) queryList() map[string]ContextMenuDef {
result := make(map[string]ContextMenuDef, len(s.menus))
for k, v := range s.menus {
result := make(map[string]ContextMenuDef, len(s.registeredMenus))
for k, v := range s.registeredMenus {
result[k] = v
}
return result
@ -78,9 +70,9 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
func (s *Service) taskAdd(t TaskAdd) error {
// If menu already exists, remove it first (replace semantics)
if _, exists := s.menus[t.Name]; exists {
if _, exists := s.registeredMenus[t.Name]; exists {
_ = s.platform.Remove(t.Name)
delete(s.menus, t.Name)
delete(s.registeredMenus, t.Name)
}
// Register on platform with a callback that broadcasts ActionItemClicked
@ -92,23 +84,23 @@ func (s *Service) taskAdd(t TaskAdd) error {
})
})
if err != nil {
return fmt.Errorf("contextmenu: platform add failed: %w", err)
return coreerr.E("contextmenu.taskAdd", "platform add failed", err)
}
s.menus[t.Name] = t.Menu
s.registeredMenus[t.Name] = t.Menu
return nil
}
func (s *Service) taskRemove(t TaskRemove) error {
if _, exists := s.menus[t.Name]; !exists {
return ErrMenuNotFound
if _, exists := s.registeredMenus[t.Name]; !exists {
return ErrorMenuNotFound
}
err := s.platform.Remove(t.Name)
if err != nil {
return fmt.Errorf("contextmenu: platform remove failed: %w", err)
return coreerr.E("contextmenu.taskRemove", "platform remove failed", err)
}
delete(s.menus, t.Name)
delete(s.registeredMenus, t.Name)
return nil
}

View file

@ -171,7 +171,7 @@ func TestTaskRemove_Bad_NotFound(t *testing.T) {
_, handled, err := c.PERFORM(TaskRemove{Name: "nonexistent"})
assert.True(t, handled)
assert.ErrorIs(t, err, ErrMenuNotFound)
assert.ErrorIs(t, err, ErrorMenuNotFound)
}
func TestQueryGet_Good(t *testing.T) {

View file

@ -1,14 +1,9 @@
// pkg/dialog/messages.go
package dialog
// TaskOpenFile shows an open file dialog. Result: []string (paths)
type TaskOpenFile struct{ Opts OpenFileOptions }
type TaskOpenFile struct{ Options OpenFileOptions }
// TaskSaveFile shows a save file dialog. Result: string (path)
type TaskSaveFile struct{ Opts SaveFileOptions }
type TaskSaveFile struct{ Options SaveFileOptions }
// TaskOpenDirectory shows a directory picker. Result: string (path)
type TaskOpenDirectory struct{ Opts OpenDirectoryOptions }
type TaskOpenDirectory struct{ Options OpenDirectoryOptions }
// TaskMessageDialog shows a message dialog. Result: string (button clicked)
type TaskMessageDialog struct{ Opts MessageDialogOptions }
type TaskMessageDialog struct{ Options MessageDialogOptions }

View file

@ -3,10 +3,10 @@ package dialog
// Platform abstracts the native dialog backend.
type Platform interface {
OpenFile(opts OpenFileOptions) ([]string, error)
SaveFile(opts SaveFileOptions) (string, error)
OpenDirectory(opts OpenDirectoryOptions) (string, error)
MessageDialog(opts MessageDialogOptions) (string, error)
OpenFile(options OpenFileOptions) ([]string, error)
SaveFile(options SaveFileOptions) (string, error)
OpenDirectory(options OpenDirectoryOptions) (string, error)
MessageDialog(options MessageDialogOptions) (string, error)
}
// DialogType represents the type of message dialog.

View file

@ -7,16 +7,13 @@ import (
"forge.lthn.ai/core/go/pkg/core"
)
// Options holds configuration for the dialog service.
type Options struct{}
// Service is a core.Service managing native dialogs via IPC.
type Service struct {
*core.ServiceRuntime[Options]
platform Platform
}
// Register creates a factory closure that captures the Platform adapter.
func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
return &Service{
@ -26,13 +23,11 @@ func Register(p Platform) func(*core.Core) (any, error) {
}
}
// OnStartup registers IPC handlers.
func (s *Service) OnStartup(ctx context.Context) error {
s.Core().RegisterTask(s.handleTask)
return nil
}
// HandleIPCEvents is auto-discovered by core.WithService.
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}
@ -40,16 +35,16 @@ func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
switch t := t.(type) {
case TaskOpenFile:
paths, err := s.platform.OpenFile(t.Opts)
paths, err := s.platform.OpenFile(t.Options)
return paths, true, err
case TaskSaveFile:
path, err := s.platform.SaveFile(t.Opts)
path, err := s.platform.SaveFile(t.Options)
return path, true, err
case TaskOpenDirectory:
path, err := s.platform.OpenDirectory(t.Opts)
path, err := s.platform.OpenDirectory(t.Options)
return path, true, err
case TaskMessageDialog:
button, err := s.platform.MessageDialog(t.Opts)
button, err := s.platform.MessageDialog(t.Options)
return button, true, err
default:
return nil, false, nil

View file

@ -11,18 +11,18 @@ import (
)
type mockPlatform struct {
openFilePaths []string
saveFilePath string
openDirPath string
messageButton string
openFileErr error
saveFileErr error
openDirErr error
messageErr error
lastOpenOpts OpenFileOptions
lastSaveOpts SaveFileOptions
lastDirOpts OpenDirectoryOptions
lastMsgOpts MessageDialogOptions
openFilePaths []string
saveFilePath string
openDirPath string
messageButton string
openFileErr error
saveFileErr error
openDirErr error
messageErr error
lastOpenOpts OpenFileOptions
lastSaveOpts SaveFileOptions
lastDirOpts OpenDirectoryOptions
lastMsgOpts MessageDialogOptions
}
func (m *mockPlatform) OpenFile(opts OpenFileOptions) ([]string, error) {
@ -70,7 +70,7 @@ func TestTaskOpenFile_Good(t *testing.T) {
mock.openFilePaths = []string{"/a.txt", "/b.txt"}
result, handled, err := c.PERFORM(TaskOpenFile{
Opts: OpenFileOptions{Title: "Pick", AllowMultiple: true},
Options: OpenFileOptions{Title: "Pick", AllowMultiple: true},
})
require.NoError(t, err)
assert.True(t, handled)
@ -83,7 +83,7 @@ func TestTaskOpenFile_Good(t *testing.T) {
func TestTaskSaveFile_Good(t *testing.T) {
_, c := newTestService(t)
result, handled, err := c.PERFORM(TaskSaveFile{
Opts: SaveFileOptions{Filename: "out.txt"},
Options: SaveFileOptions{Filename: "out.txt"},
})
require.NoError(t, err)
assert.True(t, handled)
@ -93,7 +93,7 @@ func TestTaskSaveFile_Good(t *testing.T) {
func TestTaskOpenDirectory_Good(t *testing.T) {
_, c := newTestService(t)
result, handled, err := c.PERFORM(TaskOpenDirectory{
Opts: OpenDirectoryOptions{Title: "Pick Dir"},
Options: OpenDirectoryOptions{Title: "Pick Dir"},
})
require.NoError(t, err)
assert.True(t, handled)
@ -105,7 +105,7 @@ func TestTaskMessageDialog_Good(t *testing.T) {
mock.messageButton = "Yes"
result, handled, err := c.PERFORM(TaskMessageDialog{
Opts: MessageDialogOptions{
Options: MessageDialogOptions{
Type: DialogQuestion, Title: "Confirm",
Message: "Sure?", Buttons: []string{"Yes", "No"},
},

View file

@ -156,7 +156,6 @@ This document tracks the implementation of display server features that enable A
### Focus Management
- [x] `window_focused` - Get currently focused window
- [x] `focus_set` - Set focus to specific window (alias for window_focus)
### Event Subscriptions (WebSocket)
- [x] `event_subscribe` - Subscribe to events (via WebSocket /events endpoint)
@ -215,7 +214,7 @@ This document tracks the implementation of display server features that enable A
- [x] WebSocket event subscriptions (/events endpoint)
- [x] Real-time window tracking (focus, blur, move, resize, close, create)
- [x] Theme change events
- [x] focus_set, screen_get, screen_primary, screen_at_point, screen_for_window
- [x] screen_get, screen_primary, screen_at_point, screen_for_window
### Phase 7 - Advanced Features (DONE)
- [x] `window_background_colour` - Window transparency via RGBA alpha

View file

@ -1,43 +1,29 @@
# Display
This repository is a display module for the core web3 framework. It includes a Go backend, an Angular custom element, and a full release cycle configuration.
`pkg/display` is the Core GUI display service. It owns window orchestration, layouts, menus, system tray state, dialogs, notifications, and the IPC bridge to the frontend.
## Getting Started
## Working Locally
1. **Clone the repository:**
```bash
git clone https://github.com/Snider/display.git
```
1. Run the backend tests:
```bash
go test ./pkg/display/...
```
2. Run the full workspace tests when you touch IPC contracts:
```bash
go test ./...
```
3. Build the Angular frontend:
```bash
cd ui
npm install
npm run build
```
2. **Install the dependencies:**
```bash
cd display
go mod tidy
cd ui
npm install
```
## Declarative Windows
3. **Run the development server:**
```bash
go run ./cmd/demo-cli serve
```
This will start the Go backend and serve the Angular custom element.
Windows are created from a `window.Window` spec instead of a fluent option chain.
## Building the Custom Element
Use `OpenWindow(window.Window{})` for the default app window, or `CreateWindow(window.Window{Name: "editor", Title: "Editor", URL: "/#/editor"})` when you need a named window and the returned `WindowInfo`.
Use `SetWindowBounds("editor", 100, 200, 1280, 720)` when you need to move and resize a window in one step.
To build the Angular custom element, run the following command:
```bash
cd ui
npm run build
```
This will create a single JavaScript file in the `dist` directory that you can use in any HTML page.
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
## License
This project is licensed under the EUPL-1.2 License - see the [LICENSE](LICENSE) file for details.
The same spec shape is used by layout restore, tiling, snapping, and workflow presets.

File diff suppressed because it is too large Load diff

View file

@ -46,19 +46,16 @@ func newTestConclave(t *testing.T) *core.Core {
// --- Tests ---
func TestNew(t *testing.T) {
func TestNewService(t *testing.T) {
t.Run("creates service successfully", func(t *testing.T) {
service, err := New()
assert.NoError(t, err)
assert.NotNil(t, service, "New() should return a non-nil service instance")
service := NewService()
assert.NotNil(t, service, "NewService() should return a non-nil service instance")
})
t.Run("returns independent instances", func(t *testing.T) {
service1, err1 := New()
service2, err2 := New()
assert.NoError(t, err1)
assert.NoError(t, err2)
assert.NotSame(t, service1, service2, "New() should return different instances")
service1 := NewService()
service2 := NewService()
assert.NotSame(t, service1, service2, "NewService() should return different instances")
})
}
@ -104,7 +101,7 @@ func TestConfigTask_Good(t *testing.T) {
_, c := newTestDisplayService(t)
newCfg := map[string]any{"default_width": 800}
_, handled, err := c.PERFORM(window.TaskSaveConfig{Value: newCfg})
_, handled, err := c.PERFORM(window.TaskSaveConfig{Config: newCfg})
require.NoError(t, err)
assert.True(t, handled)
@ -121,7 +118,7 @@ func TestServiceConclave_Good(t *testing.T) {
// Open a window via IPC
result, handled, err := c.PERFORM(window.TaskOpenWindow{
Opts: []window.WindowOption{window.WithName("main")},
Window: window.Window{Name: "main"},
})
require.NoError(t, err)
assert.True(t, handled)
@ -163,42 +160,29 @@ func TestServiceConclave_Bad(t *testing.T) {
// --- IPC delegation tests (full conclave) ---
func TestOpenWindow_Good(t *testing.T) {
func TestOpenWindow_Defaults_Good(t *testing.T) {
c := newTestConclave(t)
svc := core.MustServiceFor[*Service](c, "display")
t.Run("creates window with default options", func(t *testing.T) {
err := svc.OpenWindow()
assert.NoError(t, err)
err := svc.OpenWindow(window.Window{})
assert.NoError(t, err)
// Verify via IPC query
infos := svc.ListWindowInfos()
assert.GreaterOrEqual(t, len(infos), 1)
})
t.Run("creates window with custom options", func(t *testing.T) {
err := svc.OpenWindow(
window.WithName("custom-window"),
window.WithTitle("Custom Title"),
window.WithSize(640, 480),
window.WithURL("/custom"),
)
assert.NoError(t, err)
result, _, _ := c.QUERY(window.QueryWindowByName{Name: "custom-window"})
info := result.(*window.WindowInfo)
assert.Equal(t, "custom-window", info.Name)
})
info, err := svc.GetWindowInfo("main")
require.NoError(t, err)
require.NotNil(t, info)
assert.Equal(t, "main", info.Name)
assert.Equal(t, "Core", info.Title)
assert.Greater(t, info.Width, 0)
assert.Greater(t, info.Height, 0)
assert.True(t, info.Visible)
assert.False(t, info.Minimized)
}
func TestGetWindowInfo_Good(t *testing.T) {
c := newTestConclave(t)
svc := core.MustServiceFor[*Service](c, "display")
_ = svc.OpenWindow(
window.WithName("test-win"),
window.WithSize(800, 600),
)
_ = svc.OpenWindow(window.Window{Name: "test-win", Width: 800, Height: 600})
// Modify position via IPC
_, _, _ = c.PERFORM(window.TaskSetPosition{Name: "test-win", X: 100, Y: 200})
@ -226,8 +210,8 @@ func TestListWindowInfos_Good(t *testing.T) {
c := newTestConclave(t)
svc := core.MustServiceFor[*Service](c, "display")
_ = svc.OpenWindow(window.WithName("win-1"))
_ = svc.OpenWindow(window.WithName("win-2"))
_ = svc.OpenWindow(window.Window{Name: "win-1"})
_ = svc.OpenWindow(window.Window{Name: "win-2"})
infos := svc.ListWindowInfos()
assert.Len(t, infos, 2)
@ -236,7 +220,7 @@ func TestListWindowInfos_Good(t *testing.T) {
func TestSetWindowPosition_Good(t *testing.T) {
c := newTestConclave(t)
svc := core.MustServiceFor[*Service](c, "display")
_ = svc.OpenWindow(window.WithName("pos-win"))
_ = svc.OpenWindow(window.Window{Name: "pos-win"})
err := svc.SetWindowPosition("pos-win", 300, 400)
assert.NoError(t, err)
@ -257,7 +241,7 @@ func TestSetWindowPosition_Bad(t *testing.T) {
func TestSetWindowSize_Good(t *testing.T) {
c := newTestConclave(t)
svc := core.MustServiceFor[*Service](c, "display")
_ = svc.OpenWindow(window.WithName("size-win"))
_ = svc.OpenWindow(window.Window{Name: "size-win"})
err := svc.SetWindowSize("size-win", 1024, 768)
assert.NoError(t, err)
@ -267,10 +251,25 @@ func TestSetWindowSize_Good(t *testing.T) {
assert.Equal(t, 768, info.Height)
}
func TestSetWindowBounds_Good(t *testing.T) {
c := newTestConclave(t)
svc := core.MustServiceFor[*Service](c, "display")
_ = svc.OpenWindow(window.Window{Name: "bounds-win"})
err := svc.SetWindowBounds("bounds-win", 100, 200, 1024, 768)
assert.NoError(t, err)
info, _ := svc.GetWindowInfo("bounds-win")
assert.Equal(t, 100, info.X)
assert.Equal(t, 200, info.Y)
assert.Equal(t, 1024, info.Width)
assert.Equal(t, 768, info.Height)
}
func TestMaximizeWindow_Good(t *testing.T) {
c := newTestConclave(t)
svc := core.MustServiceFor[*Service](c, "display")
_ = svc.OpenWindow(window.WithName("max-win"))
_ = svc.OpenWindow(window.Window{Name: "max-win"})
err := svc.MaximizeWindow("max-win")
assert.NoError(t, err)
@ -279,10 +278,22 @@ func TestMaximizeWindow_Good(t *testing.T) {
assert.True(t, info.Maximized)
}
func TestMinimizeWindow_Good(t *testing.T) {
c := newTestConclave(t)
svc := core.MustServiceFor[*Service](c, "display")
_ = svc.OpenWindow(window.Window{Name: "min-win"})
err := svc.MinimizeWindow("min-win")
assert.NoError(t, err)
info, _ := svc.GetWindowInfo("min-win")
assert.True(t, info.Minimized)
}
func TestRestoreWindow_Good(t *testing.T) {
c := newTestConclave(t)
svc := core.MustServiceFor[*Service](c, "display")
_ = svc.OpenWindow(window.WithName("restore-win"))
_ = svc.OpenWindow(window.Window{Name: "restore-win"})
_ = svc.MaximizeWindow("restore-win")
err := svc.RestoreWindow("restore-win")
@ -295,7 +306,7 @@ func TestRestoreWindow_Good(t *testing.T) {
func TestFocusWindow_Good(t *testing.T) {
c := newTestConclave(t)
svc := core.MustServiceFor[*Service](c, "display")
_ = svc.OpenWindow(window.WithName("focus-win"))
_ = svc.OpenWindow(window.Window{Name: "focus-win"})
err := svc.FocusWindow("focus-win")
assert.NoError(t, err)
@ -307,7 +318,7 @@ func TestFocusWindow_Good(t *testing.T) {
func TestCloseWindow_Good(t *testing.T) {
c := newTestConclave(t)
svc := core.MustServiceFor[*Service](c, "display")
_ = svc.OpenWindow(window.WithName("close-win"))
_ = svc.OpenWindow(window.Window{Name: "close-win"})
err := svc.CloseWindow("close-win")
assert.NoError(t, err)
@ -320,19 +331,27 @@ func TestCloseWindow_Good(t *testing.T) {
func TestSetWindowVisibility_Good(t *testing.T) {
c := newTestConclave(t)
svc := core.MustServiceFor[*Service](c, "display")
_ = svc.OpenWindow(window.WithName("vis-win"))
_ = svc.OpenWindow(window.Window{Name: "vis-win"})
err := svc.SetWindowVisibility("vis-win", false)
assert.NoError(t, err)
info, err := svc.GetWindowInfo("vis-win")
require.NoError(t, err)
assert.False(t, info.Visible)
err = svc.SetWindowVisibility("vis-win", true)
assert.NoError(t, err)
info, err = svc.GetWindowInfo("vis-win")
require.NoError(t, err)
assert.True(t, info.Visible)
}
func TestSetWindowAlwaysOnTop_Good(t *testing.T) {
c := newTestConclave(t)
svc := core.MustServiceFor[*Service](c, "display")
_ = svc.OpenWindow(window.WithName("ontop-win"))
_ = svc.OpenWindow(window.Window{Name: "ontop-win"})
err := svc.SetWindowAlwaysOnTop("ontop-win", true)
assert.NoError(t, err)
@ -341,7 +360,7 @@ func TestSetWindowAlwaysOnTop_Good(t *testing.T) {
func TestSetWindowTitle_Good(t *testing.T) {
c := newTestConclave(t)
svc := core.MustServiceFor[*Service](c, "display")
_ = svc.OpenWindow(window.WithName("title-win"))
_ = svc.OpenWindow(window.Window{Name: "title-win"})
err := svc.SetWindowTitle("title-win", "New Title")
assert.NoError(t, err)
@ -350,8 +369,8 @@ func TestSetWindowTitle_Good(t *testing.T) {
func TestGetFocusedWindow_Good(t *testing.T) {
c := newTestConclave(t)
svc := core.MustServiceFor[*Service](c, "display")
_ = svc.OpenWindow(window.WithName("win-a"))
_ = svc.OpenWindow(window.WithName("win-b"))
_ = svc.OpenWindow(window.Window{Name: "win-a"})
_ = svc.OpenWindow(window.Window{Name: "win-b"})
_ = svc.FocusWindow("win-b")
focused := svc.GetFocusedWindow()
@ -361,7 +380,7 @@ func TestGetFocusedWindow_Good(t *testing.T) {
func TestGetFocusedWindow_NoneSelected(t *testing.T) {
c := newTestConclave(t)
svc := core.MustServiceFor[*Service](c, "display")
_ = svc.OpenWindow(window.WithName("win-a"))
_ = svc.OpenWindow(window.Window{Name: "win-a"})
focused := svc.GetFocusedWindow()
assert.Equal(t, "", focused)
@ -371,7 +390,7 @@ func TestCreateWindow_Good(t *testing.T) {
c := newTestConclave(t)
svc := core.MustServiceFor[*Service](c, "display")
info, err := svc.CreateWindow(CreateWindowOptions{
info, err := svc.CreateWindow(window.Window{
Name: "new-win",
Title: "New Window",
URL: "/new",
@ -386,34 +405,56 @@ func TestCreateWindow_Bad(t *testing.T) {
c := newTestConclave(t)
svc := core.MustServiceFor[*Service](c, "display")
_, err := svc.CreateWindow(CreateWindowOptions{})
_, err := svc.CreateWindow(window.Window{})
assert.Error(t, err)
assert.Contains(t, err.Error(), "window name is required")
}
func TestCreateWindow_Bad_NoWindowService(t *testing.T) {
svc, _ := newTestDisplayService(t)
_, err := svc.CreateWindow(window.Window{Name: "orphan-window"})
require.Error(t, err)
assert.Contains(t, err.Error(), "window service not available")
}
func TestResetWindowState_Good(t *testing.T) {
c := newTestConclave(t)
svc := core.MustServiceFor[*Service](c, "display")
err := svc.ResetWindowState()
_ = svc.OpenWindow(window.Window{Name: "reset-win"})
err := svc.SetWindowPosition("reset-win", 42, 84)
require.NoError(t, err)
err = svc.ResetWindowState()
assert.NoError(t, err)
states := svc.GetSavedWindowStates()
assert.Empty(t, states)
}
func TestGetSavedWindowStates_Good(t *testing.T) {
c := newTestConclave(t)
svc := core.MustServiceFor[*Service](c, "display")
_ = svc.OpenWindow(window.Window{Name: "saved-win"})
err := svc.SetWindowPosition("saved-win", 12, 34)
require.NoError(t, err)
states := svc.GetSavedWindowStates()
assert.NotNil(t, states)
require.Contains(t, states, "saved-win")
assert.Equal(t, 12, states["saved-win"].X)
assert.Equal(t, 34, states["saved-win"].Y)
}
func TestHandleIPCEvents_WindowOpened_Good(t *testing.T) {
c := newTestConclave(t)
// Open a window — this should trigger ActionWindowOpened
// which HandleIPCEvents should convert to a WS event
// which HandleIPCEvents should convert to a WebSocket event
result, handled, err := c.PERFORM(window.TaskOpenWindow{
Opts: []window.WindowOption{window.WithName("test")},
Window: window.Window{Name: "test"},
})
require.NoError(t, err)
assert.True(t, handled)
@ -431,8 +472,8 @@ func TestHandleListWorkspaces_Good(t *testing.T) {
})
}
func TestWSEventManager_Good(t *testing.T) {
em := NewWSEventManager()
func TestWebSocketEventManager_Good(t *testing.T) {
em := NewWebSocketEventManager()
defer em.Close()
assert.NotNil(t, em)
@ -456,7 +497,7 @@ menu:
show_dev_tools: false
`), 0o644))
s, _ := New()
s := NewService()
s.loadConfigFrom(cfgPath)
// Verify configData was populated from file
@ -466,7 +507,7 @@ menu:
}
func TestLoadConfig_Bad_MissingFile(t *testing.T) {
s, _ := New()
s := NewService()
s.loadConfigFrom(filepath.Join(t.TempDir(), "nonexistent.yaml"))
// Should not panic, configData stays at empty defaults
@ -479,7 +520,7 @@ func TestHandleConfigTask_Persists_Good(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.yaml")
s, _ := New()
s := NewService()
s.loadConfigFrom(cfgPath) // Creates empty config (file doesn't exist yet)
// Simulate a TaskSaveConfig through the handler
@ -493,7 +534,7 @@ func TestHandleConfigTask_Persists_Good(t *testing.T) {
c.ServiceStartup(context.Background(), nil)
_, handled, err := c.PERFORM(window.TaskSaveConfig{
Value: map[string]any{"default_width": 1920},
Config: map[string]any{"default_width": 1920},
})
require.NoError(t, err)
assert.True(t, handled)

View file

@ -1,6 +1,6 @@
# Backend Documentation
The backend is written in Go and uses the `github.com/Snider/display` package. It utilizes the Wails v3 framework to bridge Go and the web frontend.
The backend is written in Go and uses the `forge.lthn.ai/core/gui/pkg/display` package. It uses Wails v3 to bridge Go and the web frontend.
## Core Types
@ -8,47 +8,56 @@ The backend is written in Go and uses the `github.com/Snider/display` package. I
The `Service` struct is the main entry point for the display logic.
- **Initialization:**
- `New() (*Service, error)`: Creates a new instance of the service.
- `Startup(ctx context.Context) error`: Initializes the Wails application, builds the menu, sets up the system tray, and opens the main window.
- `NewService() *Service`: Creates a new instance of the service.
- `Register(wailsApp *application.App) func(*core.Core) (any, error)`: Captures the Wails app and registers the service with Core.
- `OnStartup(ctx context.Context) error`: Loads config and registers IPC handlers.
- **Window Management:**
- `OpenWindow(opts ...WindowOption) error`: Opens a new window with the specified options.
- `OpenWindow(spec window.Window) error`: Opens the default window using manager defaults.
- `CreateWindow(spec window.Window) (*window.WindowInfo, error)`: Opens a named window and returns its info.
- `GetWindowInfo(name string) (*window.WindowInfo, error)`: Queries a single window, including visibility, minimization, focus, and maximized state.
- `ListWindowInfos() []window.WindowInfo`: Queries all tracked windows with the same live state fields.
- `SetWindowBounds(name string, x, y, width, height int) error` - preferred when position and size change together.
- `SetWindowPosition(name string, x, y int) error`
- `SetWindowSize(name string, width, height int) error`
- `MaximizeWindow(name string) error`
- `MinimizeWindow(name string) error`
- `RestoreWindow(name string) error`
- `FocusWindow(name string) error`
- `SetWindowFullscreen(name string, fullscreen bool) error`
- `SetWindowAlwaysOnTop(name string, alwaysOnTop bool) error`
- `SetWindowVisibility(name string, visible bool) error`
- `SetWindowTitle(name, title string) error`
- `SetWindowBackgroundColour(name string, r, g, b, a uint8) error`
- `GetFocusedWindow() string`
- `ResetWindowState() error`
- `GetSavedWindowStates() map[string]window.WindowState`
- **Dialogs:**
- `ShowEnvironmentDialog()`: Displays a native dialog containing information about the runtime environment (OS, Arch, Debug mode, etc.).
Example:
### `WindowConfig` & `WindowOption`
Window configuration is handled using the Functional Options pattern. The `WindowConfig` struct holds parameters like:
- `Name`, `Title`
- `Width`, `Height`
- `URL`
- `AlwaysOnTop`, `Hidden`, `Frameless`
- Window button states (`MinimiseButtonState`, `MaximiseButtonState`, `CloseButtonState`)
```go
svc.CreateWindow(window.Window{
Name: "editor",
Title: "Editor",
URL: "/#/editor",
Width: 1200,
Height: 800,
})
**Available Options:**
- `WithName(name string)`
- `WithTitle(title string)`
- `WithWidth(width int)`
- `WithHeight(height int)`
- `WithURL(url string)`
- `WithAlwaysOnTop(bool)`
- `WithHidden(bool)`
- `WithFrameless(bool)`
- `WithMinimiseButtonState(state)`
- `WithMaximiseButtonState(state)`
- `WithCloseButtonState(state)`
svc.SetWindowBounds("editor", 100, 200, 1280, 720)
```
## Subsystems
### Menu (`menu.go`)
The `buildMenu` method constructs the application's main menu, adding standard roles like File, Edit, Window, and Help. It allows for platform-specific adjustments (e.g., AppMenu on macOS).
The `buildMenu()` method constructs the application's main menu, adding standard roles like File, Edit, Window, and Help. It also dispatches the app-specific developer actions used by the frontend.
### System Tray (`tray.go`)
The `systemTray` method initializes the system tray icon and its context menu. It supports:
The `setupTray()` method initializes the system tray icon and its context menu. It supports:
- Showing/Hiding all windows.
- Displaying environment info.
- Quitting the application.
- Attaching a hidden window for advanced tray interactions.
- Attaching tray actions that are broadcast as IPC events.
### Actions (`actions.go`)
Defines structured messages for Inter-Process Communication (IPC) or internal event handling, such as `ActionOpenWindow` which wraps `application.WebviewWindowOptions`.
### Actions (`messages.go`)
Defines structured messages for Inter-Process Communication (IPC) and internal event handling, including `window.ActionWindowOpened`, `window.ActionWindowClosed`, and `display.ActionIDECommand`.

View file

@ -1,6 +1,6 @@
# Development Guide
This guide covers how to set up the development environment, build the project, and run the demo.
This guide covers how to set up the development environment, build the project, and run the tests.
## Prerequisites
@ -12,11 +12,7 @@ This guide covers how to set up the development environment, build the project,
## Setup
1. Clone the repository:
```bash
git clone https://github.com/Snider/display.git
cd display
```
1. Clone the repository and enter the workspace.
2. Install Go dependencies:
```bash
@ -30,23 +26,6 @@ This guide covers how to set up the development environment, build the project,
cd ..
```
## Running the Demo
The project includes a CLI to facilitate development.
### Serve Mode (Web Preview)
To start a simple HTTP server that serves the frontend and a mock API:
1. Build the frontend first:
```bash
cd ui && npm run build && cd ..
```
2. Run the serve command:
```bash
go run ./cmd/demo-cli serve
```
Access the app at `http://localhost:8080`.
## Building the Project
### Frontend
@ -56,9 +35,9 @@ npm run build
```
### Backend / Application
This project is a library/module. However, it can be tested via the demo CLI or by integrating it into a Wails application entry point.
This package is exercised through Go tests and the host application that embeds it.
To run the tests:
```bash
go test ./...
go test ./pkg/display/...
```

View file

@ -1,25 +1,26 @@
# Overview
The `display` module is a core component responsible for the visual presentation and system integration of the application. It leverages **Wails v3** to create a desktop application backend in Go and **Angular** for the frontend user interface.
The `display` module is the Core GUI surface. It coordinates windows, menus, trays, dialogs, notifications, and WebSocket events through **Wails v3** on the Go side and **Angular** in `ui/` on the frontend side.
## Architecture
The project consists of two main parts:
1. **Backend (Go):** Handles window management, system tray integration, application menus, and communication with the frontend. It is located in the root directory and packaged as a Go module.
2. **Frontend (Angular):** Provides the user interface. It is located in the `ui/` directory and is built as a custom element that interacts with the backend.
1. **Backend (Go):** Handles window management, tray/menu setup, dialogs, notifications, layout persistence, and IPC dispatch.
2. **Frontend (Angular):** Provides the user interface. It lives in `ui/` and is built as a custom element that talks to the backend through the Wails runtime.
## Key Components
### Display Service (`display`)
The core service that manages the application lifecycle. It wraps the Wails application instance and exposes methods to:
- Open and configure windows.
- Manage the system tray.
- Show system dialogs (e.g., environment info).
The core service manages the application lifecycle and exposes declarative operations such as:
- `OpenWindow(window.Window{})`
- `CreateWindow(window.Window{Name: "editor", URL: "/#/editor"})`
- `SetWindowBounds("editor", 100, 200, 1280, 720)`
- `SaveLayout("coding")`
- `TileWindows(window.TileModeLeftRight, []string{"editor", "terminal"})`
- `ApplyWorkflowLayout(window.WorkflowCoding)`
### System Integration
- **Menu:** A standard application menu (File, Edit, View, etc.) is constructed in `menu.go`.
- **System Tray:** A system tray icon and menu are configured in `tray.go`, allowing quick access to common actions like showing/hiding windows or viewing environment info.
### Demo CLI
A command-line interface (`cmd/demo-cli`) is provided to run and test the display module. It includes a `serve` command for web-based development.
- **Menu:** The application menu is constructed in `buildMenu()` and dispatched through IPC.
- **System Tray:** The tray menu is configured in `setupTray()` and keeps the desktop surface in sync with the runtime.
- **Events:** Window, theme, screen, lifecycle, and tray actions are broadcast as WebSocket events for the frontend.

View file

@ -2,8 +2,8 @@ package display
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"sync"
"time"
@ -15,12 +15,12 @@ import (
type EventType string
const (
EventWindowFocus EventType = "window.focus"
EventWindowBlur EventType = "window.blur"
EventWindowMove EventType = "window.move"
EventWindowResize EventType = "window.resize"
EventWindowClose EventType = "window.close"
EventWindowCreate EventType = "window.create"
EventWindowFocus EventType = "window.focus"
EventWindowBlur EventType = "window.blur"
EventWindowMove EventType = "window.move"
EventWindowResize EventType = "window.resize"
EventWindowClose EventType = "window.close"
EventWindowCreate EventType = "window.create"
EventThemeChange EventType = "theme.change"
EventScreenChange EventType = "screen.change"
EventNotificationClick EventType = "notification.click"
@ -56,8 +56,8 @@ type Subscription struct {
EventTypes []EventType `json:"eventTypes"`
}
// WSEventManager manages WebSocket connections and event subscriptions.
type WSEventManager struct {
// WebSocketEventManager manages WebSocket connections and event subscriptions.
type WebSocketEventManager struct {
upgrader websocket.Upgrader
clients map[*websocket.Conn]*clientState
mu sync.RWMutex
@ -71,9 +71,9 @@ type clientState struct {
mu sync.RWMutex
}
// NewWSEventManager creates a new event manager.
func NewWSEventManager() *WSEventManager {
em := &WSEventManager{
// NewWebSocketEventManager creates a new WebSocket event manager.
func NewWebSocketEventManager() *WebSocketEventManager {
em := &WebSocketEventManager{
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // Allow all origins for local dev
@ -92,7 +92,7 @@ func NewWSEventManager() *WSEventManager {
}
// broadcaster sends events to all subscribed clients.
func (em *WSEventManager) broadcaster() {
func (em *WebSocketEventManager) broadcaster() {
for event := range em.eventBuffer {
em.mu.RLock()
for conn, state := range em.clients {
@ -105,7 +105,7 @@ func (em *WSEventManager) broadcaster() {
}
// clientSubscribed checks if a client is subscribed to an event type.
func (em *WSEventManager) clientSubscribed(state *clientState, eventType EventType) bool {
func (em *WebSocketEventManager) clientSubscribed(state *clientState, eventType EventType) bool {
state.mu.RLock()
defer state.mu.RUnlock()
@ -120,7 +120,7 @@ func (em *WSEventManager) clientSubscribed(state *clientState, eventType EventTy
}
// sendEvent sends an event to a specific client.
func (em *WSEventManager) sendEvent(conn *websocket.Conn, event Event) {
func (em *WebSocketEventManager) sendEvent(conn *websocket.Conn, event Event) {
em.mu.RLock()
_, exists := em.clients[conn]
em.mu.RUnlock()
@ -141,7 +141,7 @@ func (em *WSEventManager) sendEvent(conn *websocket.Conn, event Event) {
}
// HandleWebSocket handles WebSocket upgrade and connection.
func (em *WSEventManager) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
func (em *WebSocketEventManager) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := em.upgrader.Upgrade(w, r, nil)
if err != nil {
return
@ -158,7 +158,7 @@ func (em *WSEventManager) HandleWebSocket(w http.ResponseWriter, r *http.Request
}
// handleMessages processes incoming WebSocket messages.
func (em *WSEventManager) handleMessages(conn *websocket.Conn) {
func (em *WebSocketEventManager) handleMessages(conn *websocket.Conn) {
defer em.removeClient(conn)
for {
@ -189,7 +189,7 @@ func (em *WSEventManager) handleMessages(conn *websocket.Conn) {
}
// subscribe adds a subscription for a client.
func (em *WSEventManager) subscribe(conn *websocket.Conn, id string, eventTypes []EventType) {
func (em *WebSocketEventManager) subscribe(conn *websocket.Conn, id string, eventTypes []EventType) {
em.mu.RLock()
state, exists := em.clients[conn]
em.mu.RUnlock()
@ -202,7 +202,7 @@ func (em *WSEventManager) subscribe(conn *websocket.Conn, id string, eventTypes
if id == "" {
em.mu.Lock()
em.nextSubID++
id = fmt.Sprintf("sub-%d", em.nextSubID)
id = "sub-" + strconv.Itoa(em.nextSubID)
em.mu.Unlock()
}
@ -224,7 +224,7 @@ func (em *WSEventManager) subscribe(conn *websocket.Conn, id string, eventTypes
}
// unsubscribe removes a subscription for a client.
func (em *WSEventManager) unsubscribe(conn *websocket.Conn, id string) {
func (em *WebSocketEventManager) unsubscribe(conn *websocket.Conn, id string) {
em.mu.RLock()
state, exists := em.clients[conn]
em.mu.RUnlock()
@ -247,7 +247,7 @@ func (em *WSEventManager) unsubscribe(conn *websocket.Conn, id string) {
}
// listSubscriptions sends a list of active subscriptions to a client.
func (em *WSEventManager) listSubscriptions(conn *websocket.Conn) {
func (em *WebSocketEventManager) listSubscriptions(conn *websocket.Conn) {
em.mu.RLock()
state, exists := em.clients[conn]
em.mu.RUnlock()
@ -272,7 +272,7 @@ func (em *WSEventManager) listSubscriptions(conn *websocket.Conn) {
}
// removeClient removes a client and its subscriptions.
func (em *WSEventManager) removeClient(conn *websocket.Conn) {
func (em *WebSocketEventManager) removeClient(conn *websocket.Conn) {
em.mu.Lock()
delete(em.clients, conn)
em.mu.Unlock()
@ -280,7 +280,7 @@ func (em *WSEventManager) removeClient(conn *websocket.Conn) {
}
// Emit sends an event to all subscribed clients.
func (em *WSEventManager) Emit(event Event) {
func (em *WebSocketEventManager) Emit(event Event) {
event.Timestamp = time.Now().UnixMilli()
select {
case em.eventBuffer <- event:
@ -290,7 +290,7 @@ func (em *WSEventManager) Emit(event Event) {
}
// EmitWindowEvent is a helper to emit window-related events.
func (em *WSEventManager) EmitWindowEvent(eventType EventType, windowName string, data map[string]any) {
func (em *WebSocketEventManager) EmitWindowEvent(eventType EventType, windowName string, data map[string]any) {
em.Emit(Event{
Type: eventType,
Window: windowName,
@ -299,14 +299,14 @@ func (em *WSEventManager) EmitWindowEvent(eventType EventType, windowName string
}
// ConnectedClients returns the number of connected WebSocket clients.
func (em *WSEventManager) ConnectedClients() int {
func (em *WebSocketEventManager) ConnectedClients() int {
em.mu.RLock()
defer em.mu.RUnlock()
return len(em.clients)
}
// Close shuts down the event manager.
func (em *WSEventManager) Close() {
func (em *WebSocketEventManager) Close() {
em.mu.Lock()
for conn := range em.clients {
conn.Close()
@ -318,7 +318,7 @@ func (em *WSEventManager) Close() {
// AttachWindowListeners attaches event listeners to a specific window.
// Accepts window.PlatformWindow instead of *application.WebviewWindow.
func (em *WSEventManager) AttachWindowListeners(pw window.PlatformWindow) {
func (em *WebSocketEventManager) AttachWindowListeners(pw window.PlatformWindow) {
if pw == nil {
return
}

View file

@ -8,5 +8,5 @@ type ActionIDECommand struct {
Command string `json:"command"` // "save", "run", "build"
}
// EventIDECommand is the WS event type for IDE commands.
// EventIDECommand is the WebSocket event type for IDE commands.
const EventIDECommand EventType = "ide.command"

View file

@ -1,10 +1,7 @@
// pkg/dock/register.go
package dock
import "forge.lthn.ai/core/go/pkg/core"
// Register creates a factory closure that captures the Platform adapter.
// The returned function has the signature WithService requires: func(*Core) (any, error).
func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
return &Service{

View file

@ -1,4 +1,3 @@
// pkg/dock/service.go
package dock
import (
@ -7,30 +6,23 @@ import (
"forge.lthn.ai/core/go/pkg/core"
)
// Options holds configuration for the dock service.
type Options struct{}
// Service is a core.Service managing dock/taskbar operations via IPC.
// It embeds ServiceRuntime for Core access and delegates to Platform.
type Service struct {
*core.ServiceRuntime[Options]
platform Platform
}
// OnStartup registers IPC handlers.
func (s *Service) OnStartup(ctx context.Context) error {
s.Core().RegisterQuery(s.handleQuery)
s.Core().RegisterTask(s.handleTask)
return nil
}
// HandleIPCEvents is auto-discovered and registered by core.WithService.
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}
// --- Query Handlers ---
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
switch q.(type) {
case QueryVisible:
@ -40,8 +32,6 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
}
}
// --- Task Handlers ---
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
switch t := t.(type) {
case TaskShowIcon:

View file

@ -1,40 +1,25 @@
// pkg/keybinding/messages.go
package keybinding
import "errors"
// ErrAlreadyRegistered is returned when attempting to add a binding
// that already exists. Callers must TaskRemove first to rebind.
var ErrAlreadyRegistered = errors.New("keybinding: accelerator already registered")
var ErrorAlreadyRegistered = errors.New("keybinding: accelerator already registered")
// BindingInfo describes a registered keyboard shortcut.
type BindingInfo struct {
Accelerator string `json:"accelerator"`
Description string `json:"description"`
}
// --- Queries ---
// QueryList returns all registered bindings. Result: []BindingInfo
type QueryList struct{}
// --- Tasks ---
// TaskAdd registers a new keyboard shortcut. Result: nil
// Returns ErrAlreadyRegistered if the accelerator is already bound.
type TaskAdd struct {
Accelerator string `json:"accelerator"`
Description string `json:"description"`
}
// TaskRemove unregisters a keyboard shortcut. Result: nil
type TaskRemove struct {
Accelerator string `json:"accelerator"`
}
// --- Actions ---
// ActionTriggered is broadcast when a registered shortcut is activated.
type ActionTriggered struct {
Accelerator string `json:"accelerator"`
}

View file

@ -1,16 +1,13 @@
// pkg/keybinding/register.go
package keybinding
import "forge.lthn.ai/core/go/pkg/core"
// Register creates a factory closure that captures the Platform adapter.
// The returned function has the signature WithService requires: func(*Core) (any, error).
func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
return &Service{
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
platform: p,
bindings: make(map[string]BindingInfo),
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
platform: p,
registeredBindings: make(map[string]BindingInfo),
}, nil
}
}

View file

@ -3,31 +3,25 @@ package keybinding
import (
"context"
"fmt"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/go/pkg/core"
)
// Options holds configuration for the keybinding service.
type Options struct{}
// Service is a core.Service managing keyboard shortcuts via IPC.
// It maintains an in-memory registry of bindings and delegates
// platform-level registration to the Platform interface.
type Service struct {
*core.ServiceRuntime[Options]
platform Platform
bindings map[string]BindingInfo
platform Platform
registeredBindings map[string]BindingInfo
}
// OnStartup registers IPC handlers.
func (s *Service) OnStartup(ctx context.Context) error {
s.Core().RegisterQuery(s.handleQuery)
s.Core().RegisterTask(s.handleTask)
return nil
}
// HandleIPCEvents is auto-discovered and registered by core.WithService.
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}
@ -43,10 +37,9 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
}
}
// queryList reads from the in-memory registry (not platform.GetAll()).
func (s *Service) queryList() []BindingInfo {
result := make([]BindingInfo, 0, len(s.bindings))
for _, info := range s.bindings {
result := make([]BindingInfo, 0, len(s.registeredBindings))
for _, info := range s.registeredBindings {
result = append(result, info)
}
return result
@ -66,8 +59,8 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
}
func (s *Service) taskAdd(t TaskAdd) error {
if _, exists := s.bindings[t.Accelerator]; exists {
return ErrAlreadyRegistered
if _, exists := s.registeredBindings[t.Accelerator]; exists {
return ErrorAlreadyRegistered
}
// Register on platform with a callback that broadcasts ActionTriggered
@ -75,10 +68,10 @@ func (s *Service) taskAdd(t TaskAdd) error {
_ = s.Core().ACTION(ActionTriggered{Accelerator: t.Accelerator})
})
if err != nil {
return fmt.Errorf("keybinding: platform add failed: %w", err)
return coreerr.E("keybinding.taskAdd", "platform add failed", err)
}
s.bindings[t.Accelerator] = BindingInfo{
s.registeredBindings[t.Accelerator] = BindingInfo{
Accelerator: t.Accelerator,
Description: t.Description,
}
@ -86,15 +79,15 @@ func (s *Service) taskAdd(t TaskAdd) error {
}
func (s *Service) taskRemove(t TaskRemove) error {
if _, exists := s.bindings[t.Accelerator]; !exists {
return fmt.Errorf("keybinding: not registered: %s", t.Accelerator)
if _, exists := s.registeredBindings[t.Accelerator]; !exists {
return coreerr.E("keybinding.taskRemove", "not registered: "+t.Accelerator, nil)
}
err := s.platform.Remove(t.Accelerator)
if err != nil {
return fmt.Errorf("keybinding: platform remove failed: %w", err)
return coreerr.E("keybinding.taskRemove", "platform remove failed", err)
}
delete(s.bindings, t.Accelerator)
delete(s.registeredBindings, t.Accelerator)
return nil
}

View file

@ -99,7 +99,7 @@ func TestTaskAdd_Bad_Duplicate(t *testing.T) {
// Second add with same accelerator should fail
_, handled, err := c.PERFORM(TaskAdd{Accelerator: "Ctrl+S", Description: "Save Again"})
assert.True(t, handled)
assert.ErrorIs(t, err, ErrAlreadyRegistered)
assert.ErrorIs(t, err, ErrorAlreadyRegistered)
}
func TestTaskRemove_Good(t *testing.T) {

View file

@ -1,10 +1,7 @@
// pkg/lifecycle/register.go
package lifecycle
import "forge.lthn.ai/core/go/pkg/core"
// Register creates a factory closure that captures the Platform adapter.
// The returned function has the signature WithService requires: func(*Core) (any, error).
func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
return &Service{

View file

@ -1,4 +1,3 @@
// pkg/lifecycle/service.go
package lifecycle
import (
@ -7,22 +6,15 @@ import (
"forge.lthn.ai/core/go/pkg/core"
)
// Options holds configuration for the lifecycle service.
type Options struct{}
// Service is a core.Service that registers platform lifecycle callbacks
// and broadcasts corresponding IPC Actions. It implements both Startable
// and Stoppable: OnStartup registers all callbacks, OnShutdown cancels them.
type Service struct {
*core.ServiceRuntime[Options]
platform Platform
cancels []func()
}
// OnStartup registers a platform callback for each EventType and for file-open.
// Each callback broadcasts the corresponding Action via s.Core().ACTION().
func (s *Service) OnStartup(ctx context.Context) error {
// Register fire-and-forget event callbacks
eventActions := map[EventType]func(){
EventApplicationStarted: func() { _ = s.Core().ACTION(ActionApplicationStarted{}) },
EventWillTerminate: func() { _ = s.Core().ACTION(ActionWillTerminate{}) },
@ -38,7 +30,6 @@ func (s *Service) OnStartup(ctx context.Context) error {
s.cancels = append(s.cancels, cancel)
}
// Register file-open callback (carries data)
cancel := s.platform.OnOpenedWithFile(func(path string) {
_ = s.Core().ACTION(ActionOpenedWithFile{Path: path})
})
@ -47,7 +38,6 @@ func (s *Service) OnStartup(ctx context.Context) error {
return nil
}
// OnShutdown cancels all registered platform callbacks.
func (s *Service) OnShutdown(ctx context.Context) error {
for _, cancel := range s.cancels {
cancel()
@ -56,8 +46,6 @@ func (s *Service) OnShutdown(ctx context.Context) error {
return nil
}
// HandleIPCEvents is auto-discovered and registered by core.WithService.
// Lifecycle events are all outbound (platform -> IPC) so there is nothing to handle here.
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}

View file

@ -7,6 +7,7 @@ import (
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/gui/pkg/clipboard"
"forge.lthn.ai/core/gui/pkg/window"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -14,13 +15,13 @@ import (
func TestSubsystem_Good_Name(t *testing.T) {
c, _ := core.New(core.WithServiceLock())
sub := New(c)
sub := NewService(c)
assert.Equal(t, "display", sub.Name())
}
func TestSubsystem_Good_RegisterTools(t *testing.T) {
c, _ := core.New(core.WithServiceLock())
sub := New(c)
sub := NewService(c)
// RegisterTools should not panic with a real mcp.Server
server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.1.0"}, nil)
assert.NotPanics(t, func() { sub.RegisterTools(server) })
@ -34,7 +35,7 @@ type mockClipPlatform struct {
}
func (m *mockClipPlatform) Text() (string, bool) { return m.text, m.ok }
func (m *mockClipPlatform) SetText(t string) bool { m.text = t; m.ok = t != ""; return true }
func (m *mockClipPlatform) SetText(t string) bool { m.text = t; m.ok = t != ""; return true }
func TestMCP_Good_ClipboardRoundTrip(t *testing.T) {
c, err := core.New(
@ -53,6 +54,15 @@ func TestMCP_Good_ClipboardRoundTrip(t *testing.T) {
assert.Equal(t, "hello", content.Text)
}
func TestMCP_Bad_WindowCreateRequiresName(t *testing.T) {
c, _ := core.New(core.WithServiceLock())
sub := NewService(c)
_, _, err := sub.windowCreate(context.Background(), nil, window.Window{})
require.Error(t, err)
assert.Contains(t, err.Error(), "window name is required")
}
func TestMCP_Bad_NoServices(t *testing.T) {
c, _ := core.New(core.WithServiceLock())
// Without any services, QUERY should return handled=false

View file

@ -12,8 +12,8 @@ type Subsystem struct {
core *core.Core
}
// New creates a display MCP subsystem backed by the given Core instance.
func New(c *core.Core) *Subsystem {
// NewService creates a display MCP subsystem backed by the given Core instance.
func NewService(c *core.Core) *Subsystem {
return &Subsystem{core: c}
}

View file

@ -3,8 +3,8 @@ package mcp
import (
"context"
"fmt"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/gui/pkg/clipboard"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -23,7 +23,7 @@ func (s *Subsystem) clipboardRead(_ context.Context, _ *mcp.CallToolRequest, _ C
}
content, ok := result.(clipboard.ClipboardContent)
if !ok {
return nil, ClipboardReadOutput{}, fmt.Errorf("unexpected result type from clipboard read query")
return nil, ClipboardReadOutput{}, coreerr.E("mcp.clipboardRead", "unexpected result type", nil)
}
return nil, ClipboardReadOutput{Content: content.Text}, nil
}
@ -44,7 +44,7 @@ func (s *Subsystem) clipboardWrite(_ context.Context, _ *mcp.CallToolRequest, in
}
success, ok := result.(bool)
if !ok {
return nil, ClipboardWriteOutput{}, fmt.Errorf("unexpected result type from clipboard write task")
return nil, ClipboardWriteOutput{}, coreerr.E("mcp.clipboardWrite", "unexpected result type", nil)
}
return nil, ClipboardWriteOutput{Success: success}, nil
}
@ -63,7 +63,7 @@ func (s *Subsystem) clipboardHas(_ context.Context, _ *mcp.CallToolRequest, _ Cl
}
content, ok := result.(clipboard.ClipboardContent)
if !ok {
return nil, ClipboardHasOutput{}, fmt.Errorf("unexpected result type from clipboard has query")
return nil, ClipboardHasOutput{}, coreerr.E("mcp.clipboardHas", "unexpected result type", nil)
}
return nil, ClipboardHasOutput{HasContent: content.HasContent}, nil
}
@ -82,7 +82,7 @@ func (s *Subsystem) clipboardClear(_ context.Context, _ *mcp.CallToolRequest, _
}
success, ok := result.(bool)
if !ok {
return nil, ClipboardClearOutput{}, fmt.Errorf("unexpected result type from clipboard clear task")
return nil, ClipboardClearOutput{}, coreerr.E("mcp.clipboardClear", "unexpected result type", nil)
}
return nil, ClipboardClearOutput{Success: success}, nil
}

View file

@ -4,8 +4,8 @@ package mcp
import (
"context"
"encoding/json"
"fmt"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/gui/pkg/contextmenu"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -27,11 +27,11 @@ func (s *Subsystem) contextMenuAdd(_ context.Context, _ *mcp.CallToolRequest, in
// Convert map[string]any to ContextMenuDef via JSON round-trip
menuJSON, err := json.Marshal(input.Menu)
if err != nil {
return nil, ContextMenuAddOutput{}, fmt.Errorf("failed to marshal menu definition: %w", err)
return nil, ContextMenuAddOutput{}, coreerr.E("mcp.contextMenuAdd", "failed to marshal menu definition", err)
}
var menuDef contextmenu.ContextMenuDef
if err := json.Unmarshal(menuJSON, &menuDef); err != nil {
return nil, ContextMenuAddOutput{}, fmt.Errorf("failed to unmarshal menu definition: %w", err)
return nil, ContextMenuAddOutput{}, coreerr.E("mcp.contextMenuAdd", "failed to unmarshal menu definition", err)
}
_, _, err = s.core.PERFORM(contextmenu.TaskAdd{Name: input.Name, Menu: menuDef})
if err != nil {
@ -73,7 +73,7 @@ func (s *Subsystem) contextMenuGet(_ context.Context, _ *mcp.CallToolRequest, in
}
menu, ok := result.(*contextmenu.ContextMenuDef)
if !ok {
return nil, ContextMenuGetOutput{}, fmt.Errorf("unexpected result type from context menu get query")
return nil, ContextMenuGetOutput{}, coreerr.E("mcp.contextMenuGet", "unexpected result type", nil)
}
if menu == nil {
return nil, ContextMenuGetOutput{}, nil
@ -81,11 +81,11 @@ func (s *Subsystem) contextMenuGet(_ context.Context, _ *mcp.CallToolRequest, in
// Convert to map[string]any via JSON round-trip to avoid cyclic type in schema
menuJSON, err := json.Marshal(menu)
if err != nil {
return nil, ContextMenuGetOutput{}, fmt.Errorf("failed to marshal context menu: %w", err)
return nil, ContextMenuGetOutput{}, coreerr.E("mcp.contextMenuGet", "failed to marshal context menu", err)
}
var menuMap map[string]any
if err := json.Unmarshal(menuJSON, &menuMap); err != nil {
return nil, ContextMenuGetOutput{}, fmt.Errorf("failed to unmarshal context menu: %w", err)
return nil, ContextMenuGetOutput{}, coreerr.E("mcp.contextMenuGet", "failed to unmarshal context menu", err)
}
return nil, ContextMenuGetOutput{Menu: menuMap}, nil
}
@ -104,16 +104,16 @@ func (s *Subsystem) contextMenuList(_ context.Context, _ *mcp.CallToolRequest, _
}
menus, ok := result.(map[string]contextmenu.ContextMenuDef)
if !ok {
return nil, ContextMenuListOutput{}, fmt.Errorf("unexpected result type from context menu list query")
return nil, ContextMenuListOutput{}, coreerr.E("mcp.contextMenuList", "unexpected result type", nil)
}
// Convert to map[string]any via JSON round-trip to avoid cyclic type in schema
menusJSON, err := json.Marshal(menus)
if err != nil {
return nil, ContextMenuListOutput{}, fmt.Errorf("failed to marshal context menus: %w", err)
return nil, ContextMenuListOutput{}, coreerr.E("mcp.contextMenuList", "failed to marshal context menus", err)
}
var menusMap map[string]any
if err := json.Unmarshal(menusJSON, &menusMap); err != nil {
return nil, ContextMenuListOutput{}, fmt.Errorf("failed to unmarshal context menus: %w", err)
return nil, ContextMenuListOutput{}, coreerr.E("mcp.contextMenuList", "failed to unmarshal context menus", err)
}
return nil, ContextMenuListOutput{Menus: menusMap}, nil
}

View file

@ -3,8 +3,8 @@ package mcp
import (
"context"
"fmt"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/gui/pkg/dialog"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -22,7 +22,7 @@ type DialogOpenFileOutput struct {
}
func (s *Subsystem) dialogOpenFile(_ context.Context, _ *mcp.CallToolRequest, input DialogOpenFileInput) (*mcp.CallToolResult, DialogOpenFileOutput, error) {
result, _, err := s.core.PERFORM(dialog.TaskOpenFile{Opts: dialog.OpenFileOptions{
result, _, err := s.core.PERFORM(dialog.TaskOpenFile{Options: dialog.OpenFileOptions{
Title: input.Title,
Directory: input.Directory,
Filters: input.Filters,
@ -33,7 +33,7 @@ func (s *Subsystem) dialogOpenFile(_ context.Context, _ *mcp.CallToolRequest, in
}
paths, ok := result.([]string)
if !ok {
return nil, DialogOpenFileOutput{}, fmt.Errorf("unexpected result type from open file dialog")
return nil, DialogOpenFileOutput{}, coreerr.E("mcp.dialogOpenFile", "unexpected result type", nil)
}
return nil, DialogOpenFileOutput{Paths: paths}, nil
}
@ -51,7 +51,7 @@ type DialogSaveFileOutput struct {
}
func (s *Subsystem) dialogSaveFile(_ context.Context, _ *mcp.CallToolRequest, input DialogSaveFileInput) (*mcp.CallToolResult, DialogSaveFileOutput, error) {
result, _, err := s.core.PERFORM(dialog.TaskSaveFile{Opts: dialog.SaveFileOptions{
result, _, err := s.core.PERFORM(dialog.TaskSaveFile{Options: dialog.SaveFileOptions{
Title: input.Title,
Directory: input.Directory,
Filename: input.Filename,
@ -62,7 +62,7 @@ func (s *Subsystem) dialogSaveFile(_ context.Context, _ *mcp.CallToolRequest, in
}
path, ok := result.(string)
if !ok {
return nil, DialogSaveFileOutput{}, fmt.Errorf("unexpected result type from save file dialog")
return nil, DialogSaveFileOutput{}, coreerr.E("mcp.dialogSaveFile", "unexpected result type", nil)
}
return nil, DialogSaveFileOutput{Path: path}, nil
}
@ -78,7 +78,7 @@ type DialogOpenDirectoryOutput struct {
}
func (s *Subsystem) dialogOpenDirectory(_ context.Context, _ *mcp.CallToolRequest, input DialogOpenDirectoryInput) (*mcp.CallToolResult, DialogOpenDirectoryOutput, error) {
result, _, err := s.core.PERFORM(dialog.TaskOpenDirectory{Opts: dialog.OpenDirectoryOptions{
result, _, err := s.core.PERFORM(dialog.TaskOpenDirectory{Options: dialog.OpenDirectoryOptions{
Title: input.Title,
Directory: input.Directory,
}})
@ -87,7 +87,7 @@ func (s *Subsystem) dialogOpenDirectory(_ context.Context, _ *mcp.CallToolReques
}
path, ok := result.(string)
if !ok {
return nil, DialogOpenDirectoryOutput{}, fmt.Errorf("unexpected result type from open directory dialog")
return nil, DialogOpenDirectoryOutput{}, coreerr.E("mcp.dialogOpenDirectory", "unexpected result type", nil)
}
return nil, DialogOpenDirectoryOutput{Path: path}, nil
}
@ -104,7 +104,7 @@ type DialogConfirmOutput struct {
}
func (s *Subsystem) dialogConfirm(_ context.Context, _ *mcp.CallToolRequest, input DialogConfirmInput) (*mcp.CallToolResult, DialogConfirmOutput, error) {
result, _, err := s.core.PERFORM(dialog.TaskMessageDialog{Opts: dialog.MessageDialogOptions{
result, _, err := s.core.PERFORM(dialog.TaskMessageDialog{Options: dialog.MessageDialogOptions{
Type: dialog.DialogQuestion,
Title: input.Title,
Message: input.Message,
@ -115,7 +115,7 @@ func (s *Subsystem) dialogConfirm(_ context.Context, _ *mcp.CallToolRequest, inp
}
button, ok := result.(string)
if !ok {
return nil, DialogConfirmOutput{}, fmt.Errorf("unexpected result type from confirm dialog")
return nil, DialogConfirmOutput{}, coreerr.E("mcp.dialogConfirm", "unexpected result type", nil)
}
return nil, DialogConfirmOutput{Button: button}, nil
}
@ -131,7 +131,7 @@ type DialogPromptOutput struct {
}
func (s *Subsystem) dialogPrompt(_ context.Context, _ *mcp.CallToolRequest, input DialogPromptInput) (*mcp.CallToolResult, DialogPromptOutput, error) {
result, _, err := s.core.PERFORM(dialog.TaskMessageDialog{Opts: dialog.MessageDialogOptions{
result, _, err := s.core.PERFORM(dialog.TaskMessageDialog{Options: dialog.MessageDialogOptions{
Type: dialog.DialogInfo,
Title: input.Title,
Message: input.Message,
@ -142,7 +142,7 @@ func (s *Subsystem) dialogPrompt(_ context.Context, _ *mcp.CallToolRequest, inpu
}
button, ok := result.(string)
if !ok {
return nil, DialogPromptOutput{}, fmt.Errorf("unexpected result type from prompt dialog")
return nil, DialogPromptOutput{}, coreerr.E("mcp.dialogPrompt", "unexpected result type", nil)
}
return nil, DialogPromptOutput{Button: button}, nil
}

View file

@ -3,8 +3,8 @@ package mcp
import (
"context"
"fmt"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/gui/pkg/environment"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -23,7 +23,7 @@ func (s *Subsystem) themeGet(_ context.Context, _ *mcp.CallToolRequest, _ ThemeG
}
theme, ok := result.(environment.ThemeInfo)
if !ok {
return nil, ThemeGetOutput{}, fmt.Errorf("unexpected result type from theme query")
return nil, ThemeGetOutput{}, coreerr.E("mcp.themeGet", "unexpected result type", nil)
}
return nil, ThemeGetOutput{Theme: theme}, nil
}
@ -42,7 +42,7 @@ func (s *Subsystem) themeSystem(_ context.Context, _ *mcp.CallToolRequest, _ The
}
info, ok := result.(environment.EnvironmentInfo)
if !ok {
return nil, ThemeSystemOutput{}, fmt.Errorf("unexpected result type from environment info query")
return nil, ThemeSystemOutput{}, coreerr.E("mcp.themeSystem", "unexpected result type", nil)
}
return nil, ThemeSystemOutput{Info: info}, nil
}

View file

@ -3,8 +3,8 @@ package mcp
import (
"context"
"fmt"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/gui/pkg/window"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -57,7 +57,7 @@ func (s *Subsystem) layoutList(_ context.Context, _ *mcp.CallToolRequest, _ Layo
}
layouts, ok := result.([]window.LayoutInfo)
if !ok {
return nil, LayoutListOutput{}, fmt.Errorf("unexpected result type from layout list query")
return nil, LayoutListOutput{}, coreerr.E("mcp.layoutList", "unexpected result type", nil)
}
return nil, LayoutListOutput{Layouts: layouts}, nil
}
@ -95,7 +95,7 @@ func (s *Subsystem) layoutGet(_ context.Context, _ *mcp.CallToolRequest, input L
}
layout, ok := result.(*window.Layout)
if !ok {
return nil, LayoutGetOutput{}, fmt.Errorf("unexpected result type from layout get query")
return nil, LayoutGetOutput{}, coreerr.E("mcp.layoutGet", "unexpected result type", nil)
}
return nil, LayoutGetOutput{Layout: layout}, nil
}
@ -136,6 +136,43 @@ func (s *Subsystem) layoutSnap(_ context.Context, _ *mcp.CallToolRequest, input
return nil, LayoutSnapOutput{Success: true}, nil
}
// --- layout_stack ---
type LayoutStackInput struct {
Windows []string `json:"windows,omitempty"`
OffsetX int `json:"offsetX"`
OffsetY int `json:"offsetY"`
}
type LayoutStackOutput struct {
Success bool `json:"success"`
}
func (s *Subsystem) layoutStack(_ context.Context, _ *mcp.CallToolRequest, input LayoutStackInput) (*mcp.CallToolResult, LayoutStackOutput, error) {
_, _, err := s.core.PERFORM(window.TaskStackWindows{Windows: input.Windows, OffsetX: input.OffsetX, OffsetY: input.OffsetY})
if err != nil {
return nil, LayoutStackOutput{}, err
}
return nil, LayoutStackOutput{Success: true}, nil
}
// --- layout_workflow ---
type LayoutWorkflowInput struct {
Workflow string `json:"workflow"`
Windows []string `json:"windows,omitempty"`
}
type LayoutWorkflowOutput struct {
Success bool `json:"success"`
}
func (s *Subsystem) layoutWorkflow(_ context.Context, _ *mcp.CallToolRequest, input LayoutWorkflowInput) (*mcp.CallToolResult, LayoutWorkflowOutput, error) {
_, _, err := s.core.PERFORM(window.TaskApplyWorkflow{Workflow: input.Workflow, Windows: input.Windows})
if err != nil {
return nil, LayoutWorkflowOutput{}, err
}
return nil, LayoutWorkflowOutput{Success: true}, nil
}
// --- Registration ---
func (s *Subsystem) registerLayoutTools(server *mcp.Server) {
@ -145,5 +182,7 @@ func (s *Subsystem) registerLayoutTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{Name: "layout_delete", Description: "Delete a saved layout"}, s.layoutDelete)
mcp.AddTool(server, &mcp.Tool{Name: "layout_get", Description: "Get a specific layout by name"}, s.layoutGet)
mcp.AddTool(server, &mcp.Tool{Name: "layout_tile", Description: "Tile windows in a grid arrangement"}, s.layoutTile)
mcp.AddTool(server, &mcp.Tool{Name: "layout_snap", Description: "Snap a window to a screen edge or corner"}, s.layoutSnap)
mcp.AddTool(server, &mcp.Tool{Name: "layout_snap", Description: "Snap a window to a screen edge, corner, or center"}, s.layoutSnap)
mcp.AddTool(server, &mcp.Tool{Name: "layout_stack", Description: "Stack windows in a cascade pattern"}, s.layoutStack)
mcp.AddTool(server, &mcp.Tool{Name: "layout_workflow", Description: "Apply a preset workflow layout"}, s.layoutWorkflow)
}

View file

@ -3,8 +3,8 @@ package mcp
import (
"context"
"fmt"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/gui/pkg/notification"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -21,7 +21,7 @@ type NotificationShowOutput struct {
}
func (s *Subsystem) notificationShow(_ context.Context, _ *mcp.CallToolRequest, input NotificationShowInput) (*mcp.CallToolResult, NotificationShowOutput, error) {
_, _, err := s.core.PERFORM(notification.TaskSend{Opts: notification.NotificationOptions{
_, _, err := s.core.PERFORM(notification.TaskSend{Options: notification.NotificationOptions{
Title: input.Title,
Message: input.Message,
Subtitle: input.Subtitle,
@ -46,7 +46,7 @@ func (s *Subsystem) notificationPermissionRequest(_ context.Context, _ *mcp.Call
}
granted, ok := result.(bool)
if !ok {
return nil, NotificationPermissionRequestOutput{}, fmt.Errorf("unexpected result type from notification permission request")
return nil, NotificationPermissionRequestOutput{}, coreerr.E("mcp.notificationPermissionRequest", "unexpected result type from notification permission request", nil)
}
return nil, NotificationPermissionRequestOutput{Granted: granted}, nil
}
@ -65,7 +65,7 @@ func (s *Subsystem) notificationPermissionCheck(_ context.Context, _ *mcp.CallTo
}
status, ok := result.(notification.PermissionStatus)
if !ok {
return nil, NotificationPermissionCheckOutput{}, fmt.Errorf("unexpected result type from notification permission check")
return nil, NotificationPermissionCheckOutput{}, coreerr.E("mcp.notificationPermissionCheck", "unexpected result type from notification permission check", nil)
}
return nil, NotificationPermissionCheckOutput{Granted: status.Granted}, nil
}

View file

@ -3,9 +3,10 @@ package mcp
import (
"context"
"fmt"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/gui/pkg/screen"
"forge.lthn.ai/core/gui/pkg/window"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -23,7 +24,7 @@ func (s *Subsystem) screenList(_ context.Context, _ *mcp.CallToolRequest, _ Scre
}
screens, ok := result.([]screen.Screen)
if !ok {
return nil, ScreenListOutput{}, fmt.Errorf("unexpected result type from screen list query")
return nil, ScreenListOutput{}, coreerr.E("mcp.screenList", "unexpected result type", nil)
}
return nil, ScreenListOutput{Screens: screens}, nil
}
@ -44,7 +45,7 @@ func (s *Subsystem) screenGet(_ context.Context, _ *mcp.CallToolRequest, input S
}
scr, ok := result.(*screen.Screen)
if !ok {
return nil, ScreenGetOutput{}, fmt.Errorf("unexpected result type from screen get query")
return nil, ScreenGetOutput{}, coreerr.E("mcp.screenGet", "unexpected result type", nil)
}
return nil, ScreenGetOutput{Screen: scr}, nil
}
@ -63,7 +64,7 @@ func (s *Subsystem) screenPrimary(_ context.Context, _ *mcp.CallToolRequest, _ S
}
scr, ok := result.(*screen.Screen)
if !ok {
return nil, ScreenPrimaryOutput{}, fmt.Errorf("unexpected result type from screen primary query")
return nil, ScreenPrimaryOutput{}, coreerr.E("mcp.screenPrimary", "unexpected result type", nil)
}
return nil, ScreenPrimaryOutput{Screen: scr}, nil
}
@ -85,7 +86,7 @@ func (s *Subsystem) screenAtPoint(_ context.Context, _ *mcp.CallToolRequest, inp
}
scr, ok := result.(*screen.Screen)
if !ok {
return nil, ScreenAtPointOutput{}, fmt.Errorf("unexpected result type from screen at point query")
return nil, ScreenAtPointOutput{}, coreerr.E("mcp.screenAtPoint", "unexpected result type", nil)
}
return nil, ScreenAtPointOutput{Screen: scr}, nil
}
@ -104,11 +105,39 @@ func (s *Subsystem) screenWorkAreas(_ context.Context, _ *mcp.CallToolRequest, _
}
areas, ok := result.([]screen.Rect)
if !ok {
return nil, ScreenWorkAreasOutput{}, fmt.Errorf("unexpected result type from screen work areas query")
return nil, ScreenWorkAreasOutput{}, coreerr.E("mcp.screenWorkAreas", "unexpected result type", nil)
}
return nil, ScreenWorkAreasOutput{WorkAreas: areas}, nil
}
// --- screen_for_window ---
type ScreenForWindowInput struct {
Name string `json:"name"`
}
type ScreenForWindowOutput struct {
Screen *screen.Screen `json:"screen"`
}
func (s *Subsystem) screenForWindow(_ context.Context, _ *mcp.CallToolRequest, input ScreenForWindowInput) (*mcp.CallToolResult, ScreenForWindowOutput, error) {
result, _, err := s.core.QUERY(window.QueryWindowByName{Name: input.Name})
if err != nil {
return nil, ScreenForWindowOutput{}, err
}
info, _ := result.(*window.WindowInfo)
if info == nil {
return nil, ScreenForWindowOutput{}, nil
}
centerX := info.X + info.Width/2
centerY := info.Y + info.Height/2
screenResult, _, err := s.core.QUERY(screen.QueryAtPoint{X: centerX, Y: centerY})
if err != nil {
return nil, ScreenForWindowOutput{}, err
}
scr, _ := screenResult.(*screen.Screen)
return nil, ScreenForWindowOutput{Screen: scr}, nil
}
// --- Registration ---
func (s *Subsystem) registerScreenTools(server *mcp.Server) {
@ -117,4 +146,5 @@ func (s *Subsystem) registerScreenTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{Name: "screen_primary", Description: "Get the primary screen"}, s.screenPrimary)
mcp.AddTool(server, &mcp.Tool{Name: "screen_at_point", Description: "Get the screen at a specific point"}, s.screenAtPoint)
mcp.AddTool(server, &mcp.Tool{Name: "screen_work_areas", Description: "Get work areas for all screens"}, s.screenWorkAreas)
mcp.AddTool(server, &mcp.Tool{Name: "screen_for_window", Description: "Get the screen containing a window"}, s.screenForWindow)
}

View file

@ -3,8 +3,8 @@ package mcp
import (
"context"
"fmt"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/gui/pkg/systray"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -70,7 +70,7 @@ func (s *Subsystem) trayInfo(_ context.Context, _ *mcp.CallToolRequest, _ TrayIn
}
config, ok := result.(map[string]any)
if !ok {
return nil, TrayInfoOutput{}, fmt.Errorf("unexpected result type from tray config query")
return nil, TrayInfoOutput{}, coreerr.E("mcp.trayInfo", "unexpected result type", nil)
}
return nil, TrayInfoOutput{Config: config}, nil
}

View file

@ -3,8 +3,8 @@ package mcp
import (
"context"
"fmt"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/gui/pkg/webview"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -105,7 +105,7 @@ func (s *Subsystem) webviewScreenshot(_ context.Context, _ *mcp.CallToolRequest,
}
sr, ok := result.(webview.ScreenshotResult)
if !ok {
return nil, WebviewScreenshotOutput{}, fmt.Errorf("unexpected result type from webview screenshot")
return nil, WebviewScreenshotOutput{}, coreerr.E("mcp.webviewScreenshot", "unexpected result type", nil)
}
return nil, WebviewScreenshotOutput{Base64: sr.Base64, MimeType: sr.MimeType}, nil
}
@ -248,7 +248,7 @@ func (s *Subsystem) webviewConsole(_ context.Context, _ *mcp.CallToolRequest, in
}
msgs, ok := result.([]webview.ConsoleMessage)
if !ok {
return nil, WebviewConsoleOutput{}, fmt.Errorf("unexpected result type from webview console query")
return nil, WebviewConsoleOutput{}, coreerr.E("mcp.webviewConsole", "unexpected result type", nil)
}
return nil, WebviewConsoleOutput{Messages: msgs}, nil
}
@ -289,7 +289,7 @@ func (s *Subsystem) webviewQuery(_ context.Context, _ *mcp.CallToolRequest, inpu
}
el, ok := result.(*webview.ElementInfo)
if !ok {
return nil, WebviewQueryOutput{}, fmt.Errorf("unexpected result type from webview query")
return nil, WebviewQueryOutput{}, coreerr.E("mcp.webviewQuery", "unexpected result type", nil)
}
return nil, WebviewQueryOutput{Element: el}, nil
}
@ -312,7 +312,7 @@ func (s *Subsystem) webviewQueryAll(_ context.Context, _ *mcp.CallToolRequest, i
}
els, ok := result.([]*webview.ElementInfo)
if !ok {
return nil, WebviewQueryAllOutput{}, fmt.Errorf("unexpected result type from webview query all")
return nil, WebviewQueryAllOutput{}, coreerr.E("mcp.webviewQueryAll", "unexpected result type", nil)
}
return nil, WebviewQueryAllOutput{Elements: els}, nil
}
@ -335,7 +335,7 @@ func (s *Subsystem) webviewDOMTree(_ context.Context, _ *mcp.CallToolRequest, in
}
html, ok := result.(string)
if !ok {
return nil, WebviewDOMTreeOutput{}, fmt.Errorf("unexpected result type from webview DOM tree query")
return nil, WebviewDOMTreeOutput{}, coreerr.E("mcp.webviewDOMTree", "unexpected result type", nil)
}
return nil, WebviewDOMTreeOutput{HTML: html}, nil
}
@ -357,7 +357,7 @@ func (s *Subsystem) webviewURL(_ context.Context, _ *mcp.CallToolRequest, input
}
url, ok := result.(string)
if !ok {
return nil, WebviewURLOutput{}, fmt.Errorf("unexpected result type from webview URL query")
return nil, WebviewURLOutput{}, coreerr.E("mcp.webviewURL", "unexpected result type", nil)
}
return nil, WebviewURLOutput{URL: url}, nil
}
@ -379,7 +379,7 @@ func (s *Subsystem) webviewTitle(_ context.Context, _ *mcp.CallToolRequest, inpu
}
title, ok := result.(string)
if !ok {
return nil, WebviewTitleOutput{}, fmt.Errorf("unexpected result type from webview title query")
return nil, WebviewTitleOutput{}, coreerr.E("mcp.webviewTitle", "unexpected result type", nil)
}
return nil, WebviewTitleOutput{Title: title}, nil
}

View file

@ -3,8 +3,8 @@ package mcp
import (
"context"
"fmt"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/gui/pkg/window"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -23,7 +23,7 @@ func (s *Subsystem) windowList(_ context.Context, _ *mcp.CallToolRequest, _ Wind
}
windows, ok := result.([]window.WindowInfo)
if !ok {
return nil, WindowListOutput{}, fmt.Errorf("unexpected result type from window list query")
return nil, WindowListOutput{}, coreerr.E("mcp.windowList", "unexpected result type from window list query", nil)
}
return nil, WindowListOutput{Windows: windows}, nil
}
@ -44,7 +44,7 @@ func (s *Subsystem) windowGet(_ context.Context, _ *mcp.CallToolRequest, input W
}
info, ok := result.(*window.WindowInfo)
if !ok {
return nil, WindowGetOutput{}, fmt.Errorf("unexpected result type from window get query")
return nil, WindowGetOutput{}, coreerr.E("mcp.windowGet", "unexpected result type from window get query", nil)
}
return nil, WindowGetOutput{Window: info}, nil
}
@ -63,7 +63,7 @@ func (s *Subsystem) windowFocused(_ context.Context, _ *mcp.CallToolRequest, _ W
}
windows, ok := result.([]window.WindowInfo)
if !ok {
return nil, WindowFocusedOutput{}, fmt.Errorf("unexpected result type from window list query")
return nil, WindowFocusedOutput{}, coreerr.E("mcp.windowFocused", "unexpected result type from window list query", nil)
}
for _, w := range windows {
if w.Focused {
@ -75,42 +75,23 @@ func (s *Subsystem) windowFocused(_ context.Context, _ *mcp.CallToolRequest, _ W
// --- window_create ---
type WindowCreateInput struct {
Name string `json:"name"`
Title string `json:"title,omitempty"`
URL string `json:"url,omitempty"`
X int `json:"x,omitempty"`
Y int `json:"y,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
}
type WindowCreateOutput struct {
Window window.WindowInfo `json:"window"`
}
func (s *Subsystem) windowCreate(_ context.Context, _ *mcp.CallToolRequest, input WindowCreateInput) (*mcp.CallToolResult, WindowCreateOutput, error) {
opts := []window.WindowOption{
window.WithName(input.Name),
func (s *Subsystem) windowCreate(_ context.Context, _ *mcp.CallToolRequest, input window.Window) (*mcp.CallToolResult, WindowCreateOutput, error) {
if input.Name == "" {
return nil, WindowCreateOutput{}, coreerr.E("mcp.windowCreate", "window name is required", nil)
}
if input.Title != "" {
opts = append(opts, window.WithTitle(input.Title))
}
if input.URL != "" {
opts = append(opts, window.WithURL(input.URL))
}
if input.Width > 0 || input.Height > 0 {
opts = append(opts, window.WithSize(input.Width, input.Height))
}
if input.X != 0 || input.Y != 0 {
opts = append(opts, window.WithPosition(input.X, input.Y))
}
result, _, err := s.core.PERFORM(window.TaskOpenWindow{Opts: opts})
result, _, err := s.core.PERFORM(window.TaskOpenWindow{
Window: input,
})
if err != nil {
return nil, WindowCreateOutput{}, err
}
info, ok := result.(window.WindowInfo)
if !ok {
return nil, WindowCreateOutput{}, fmt.Errorf("unexpected result type from window create task")
return nil, WindowCreateOutput{}, coreerr.E("mcp.windowCreate", "unexpected result type from window create task", nil)
}
return nil, WindowCreateOutput{Window: info}, nil
}
@ -163,7 +144,7 @@ type WindowSizeOutput struct {
}
func (s *Subsystem) windowSize(_ context.Context, _ *mcp.CallToolRequest, input WindowSizeInput) (*mcp.CallToolResult, WindowSizeOutput, error) {
_, _, err := s.core.PERFORM(window.TaskSetSize{Name: input.Name, W: input.Width, H: input.Height})
_, _, err := s.core.PERFORM(window.TaskSetSize{Name: input.Name, Width: input.Width, Height: input.Height})
if err != nil {
return nil, WindowSizeOutput{}, err
}
@ -184,11 +165,9 @@ type WindowBoundsOutput struct {
}
func (s *Subsystem) windowBounds(_ context.Context, _ *mcp.CallToolRequest, input WindowBoundsInput) (*mcp.CallToolResult, WindowBoundsOutput, error) {
_, _, err := s.core.PERFORM(window.TaskSetPosition{Name: input.Name, X: input.X, Y: input.Y})
if err != nil {
return nil, WindowBoundsOutput{}, err
}
_, _, err = s.core.PERFORM(window.TaskSetSize{Name: input.Name, W: input.Width, H: input.Height})
_, _, err := s.core.PERFORM(window.TaskSetBounds{
Name: input.Name, X: input.X, Y: input.Y, Width: input.Width, Height: input.Height,
})
if err != nil {
return nil, WindowBoundsOutput{}, err
}
@ -205,7 +184,7 @@ type WindowMaximizeOutput struct {
}
func (s *Subsystem) windowMaximize(_ context.Context, _ *mcp.CallToolRequest, input WindowMaximizeInput) (*mcp.CallToolResult, WindowMaximizeOutput, error) {
_, _, err := s.core.PERFORM(window.TaskMaximise{Name: input.Name})
_, _, err := s.core.PERFORM(window.TaskMaximize{Name: input.Name})
if err != nil {
return nil, WindowMaximizeOutput{}, err
}
@ -222,7 +201,7 @@ type WindowMinimizeOutput struct {
}
func (s *Subsystem) windowMinimize(_ context.Context, _ *mcp.CallToolRequest, input WindowMinimizeInput) (*mcp.CallToolResult, WindowMinimizeOutput, error) {
_, _, err := s.core.PERFORM(window.TaskMinimise{Name: input.Name})
_, _, err := s.core.PERFORM(window.TaskMinimize{Name: input.Name})
if err != nil {
return nil, WindowMinimizeOutput{}, err
}
@ -281,6 +260,27 @@ func (s *Subsystem) windowTitle(_ context.Context, _ *mcp.CallToolRequest, input
return nil, WindowTitleOutput{Success: true}, nil
}
// --- window_title_get ---
type WindowTitleGetInput struct {
Name string `json:"name"`
}
type WindowTitleGetOutput struct {
Title string `json:"title"`
}
func (s *Subsystem) windowTitleGet(_ context.Context, _ *mcp.CallToolRequest, input WindowTitleGetInput) (*mcp.CallToolResult, WindowTitleGetOutput, error) {
result, _, err := s.core.QUERY(window.QueryWindowByName{Name: input.Name})
if err != nil {
return nil, WindowTitleGetOutput{}, err
}
info, _ := result.(*window.WindowInfo)
if info == nil {
return nil, WindowTitleGetOutput{}, nil
}
return nil, WindowTitleGetOutput{Title: info.Title}, nil
}
// --- window_visibility ---
type WindowVisibilityInput struct {
@ -299,6 +299,47 @@ func (s *Subsystem) windowVisibility(_ context.Context, _ *mcp.CallToolRequest,
return nil, WindowVisibilityOutput{Success: true}, nil
}
// --- window_always_on_top ---
type WindowAlwaysOnTopInput struct {
Name string `json:"name"`
AlwaysOnTop bool `json:"alwaysOnTop"`
}
type WindowAlwaysOnTopOutput struct {
Success bool `json:"success"`
}
func (s *Subsystem) windowAlwaysOnTop(_ context.Context, _ *mcp.CallToolRequest, input WindowAlwaysOnTopInput) (*mcp.CallToolResult, WindowAlwaysOnTopOutput, error) {
_, _, err := s.core.PERFORM(window.TaskSetAlwaysOnTop{Name: input.Name, AlwaysOnTop: input.AlwaysOnTop})
if err != nil {
return nil, WindowAlwaysOnTopOutput{}, err
}
return nil, WindowAlwaysOnTopOutput{Success: true}, nil
}
// --- window_background_colour ---
type WindowBackgroundColourInput struct {
Name string `json:"name"`
Red uint8 `json:"red"`
Green uint8 `json:"green"`
Blue uint8 `json:"blue"`
Alpha uint8 `json:"alpha"`
}
type WindowBackgroundColourOutput struct {
Success bool `json:"success"`
}
func (s *Subsystem) windowBackgroundColour(_ context.Context, _ *mcp.CallToolRequest, input WindowBackgroundColourInput) (*mcp.CallToolResult, WindowBackgroundColourOutput, error) {
_, _, err := s.core.PERFORM(window.TaskSetBackgroundColour{
Name: input.Name, Red: input.Red, Green: input.Green, Blue: input.Blue, Alpha: input.Alpha,
})
if err != nil {
return nil, WindowBackgroundColourOutput{}, err
}
return nil, WindowBackgroundColourOutput{Success: true}, nil
}
// --- window_fullscreen ---
type WindowFullscreenInput struct {
@ -323,16 +364,19 @@ func (s *Subsystem) registerWindowTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{Name: "window_list", Description: "List all application windows"}, s.windowList)
mcp.AddTool(server, &mcp.Tool{Name: "window_get", Description: "Get information about a specific window"}, s.windowGet)
mcp.AddTool(server, &mcp.Tool{Name: "window_focused", Description: "Get the currently focused window"}, s.windowFocused)
mcp.AddTool(server, &mcp.Tool{Name: "window_create", Description: "Create a new application window"}, s.windowCreate)
mcp.AddTool(server, &mcp.Tool{Name: "window_create", Description: "Create a new named application window"}, s.windowCreate)
mcp.AddTool(server, &mcp.Tool{Name: "window_close", Description: "Close an application window"}, s.windowClose)
mcp.AddTool(server, &mcp.Tool{Name: "window_position", Description: "Set the position of a window"}, s.windowPosition)
mcp.AddTool(server, &mcp.Tool{Name: "window_size", Description: "Set the size of a window"}, s.windowSize)
mcp.AddTool(server, &mcp.Tool{Name: "window_bounds", Description: "Set both position and size of a window"}, s.windowBounds)
mcp.AddTool(server, &mcp.Tool{Name: "window_maximize", Description: "Maximise a window"}, s.windowMaximize)
mcp.AddTool(server, &mcp.Tool{Name: "window_minimize", Description: "Minimise a window"}, s.windowMinimize)
mcp.AddTool(server, &mcp.Tool{Name: "window_restore", Description: "Restore a maximised or minimised window"}, s.windowRestore)
mcp.AddTool(server, &mcp.Tool{Name: "window_maximize", Description: "Maximize a window"}, s.windowMaximize)
mcp.AddTool(server, &mcp.Tool{Name: "window_minimize", Description: "Minimize a window"}, s.windowMinimize)
mcp.AddTool(server, &mcp.Tool{Name: "window_restore", Description: "Restore a maximized or minimized window"}, s.windowRestore)
mcp.AddTool(server, &mcp.Tool{Name: "window_focus", Description: "Bring a window to the front"}, s.windowFocus)
mcp.AddTool(server, &mcp.Tool{Name: "window_title", Description: "Set the title of a window"}, s.windowTitle)
mcp.AddTool(server, &mcp.Tool{Name: "window_title_get", Description: "Get the title of a window"}, s.windowTitleGet)
mcp.AddTool(server, &mcp.Tool{Name: "window_visibility", Description: "Show or hide a window"}, s.windowVisibility)
mcp.AddTool(server, &mcp.Tool{Name: "window_always_on_top", Description: "Pin a window above others"}, s.windowAlwaysOnTop)
mcp.AddTool(server, &mcp.Tool{Name: "window_background_colour", Description: "Set a window background colour"}, s.windowBackgroundColour)
mcp.AddTool(server, &mcp.Tool{Name: "window_fullscreen", Description: "Set a window to fullscreen mode"}, s.windowFullscreen)
}

View file

@ -1,16 +1,9 @@
package menu
// QueryConfig requests this service's config section from the display orchestrator.
// Result: map[string]any
type QueryConfig struct{}
// QueryGetAppMenu returns the current app menu item descriptors.
// Result: []MenuItem
type QueryGetAppMenu struct{}
// TaskSetAppMenu sets the application menu. OnClick closures work because
// core/go IPC is in-process (no serialisation boundary).
type TaskSetAppMenu struct{ Items []MenuItem }
// TaskSaveConfig persists this service's config section via the display orchestrator.
type TaskSaveConfig struct{ Value map[string]any }
type TaskSaveConfig struct{ Config map[string]any }

View file

@ -2,7 +2,6 @@ package menu
import "forge.lthn.ai/core/go/pkg/core"
// Register creates a factory closure that captures the Platform adapter.
func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
return &Service{

View file

@ -6,24 +6,21 @@ import (
"forge.lthn.ai/core/go/pkg/core"
)
// Options holds configuration for the menu service.
type Options struct{}
// Service is a core.Service managing application menus via IPC.
type Service struct {
*core.ServiceRuntime[Options]
manager *Manager
platform Platform
items []MenuItem // last-set menu items for QueryGetAppMenu
menuItems []MenuItem
showDevTools bool
}
// OnStartup queries config and registers IPC handlers.
func (s *Service) OnStartup(ctx context.Context) error {
cfg, handled, _ := s.Core().QUERY(QueryConfig{})
configValue, handled, _ := s.Core().QUERY(QueryConfig{})
if handled {
if mCfg, ok := cfg.(map[string]any); ok {
s.applyConfig(mCfg)
if menuConfig, ok := configValue.(map[string]any); ok {
s.applyConfig(menuConfig)
}
}
s.Core().RegisterQuery(s.handleQuery)
@ -31,20 +28,18 @@ func (s *Service) OnStartup(ctx context.Context) error {
return nil
}
func (s *Service) applyConfig(cfg map[string]any) {
if v, ok := cfg["show_dev_tools"]; ok {
func (s *Service) applyConfig(configData map[string]any) {
if v, ok := configData["show_dev_tools"]; ok {
if show, ok := v.(bool); ok {
s.showDevTools = show
}
}
}
// ShowDevTools returns whether developer tools menu items should be shown.
func (s *Service) ShowDevTools() bool {
return s.showDevTools
}
// HandleIPCEvents is auto-discovered and registered by core.WithService.
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}
@ -52,7 +47,7 @@ func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
switch q.(type) {
case QueryGetAppMenu:
return s.items, true, nil
return s.menuItems, true, nil
default:
return nil, false, nil
}
@ -61,7 +56,7 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
switch t := t.(type) {
case TaskSetAppMenu:
s.items = t.Items
s.menuItems = t.Items
s.manager.SetApplicationMenu(t.Items)
return nil, true, nil
default:
@ -69,7 +64,6 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
}
}
// Manager returns the underlying menu Manager.
func (s *Service) Manager() *Manager {
return s.manager
}

View file

@ -1,14 +1,9 @@
// pkg/notification/messages.go
package notification
// QueryPermission checks notification authorisation. Result: PermissionStatus
type QueryPermission struct{}
// TaskSend sends a notification. Falls back to dialog if platform fails.
type TaskSend struct{ Opts NotificationOptions }
type TaskSend struct{ Options NotificationOptions }
// TaskRequestPermission requests notification authorisation. Result: bool (granted)
type TaskRequestPermission struct{}
// ActionNotificationClicked is broadcast when a notification is clicked (future).
type ActionNotificationClicked struct{ ID string }

View file

@ -3,7 +3,7 @@ package notification
// Platform abstracts the native notification backend.
type Platform interface {
Send(opts NotificationOptions) error
Send(options NotificationOptions) error
RequestPermission() (bool, error)
CheckPermission() (bool, error)
}

View file

@ -3,23 +3,20 @@ package notification
import (
"context"
"fmt"
"strconv"
"time"
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/gui/pkg/dialog"
)
// Options holds configuration for the notification service.
type Options struct{}
// Service is a core.Service managing notifications via IPC.
type Service struct {
*core.ServiceRuntime[Options]
platform Platform
}
// Register creates a factory closure that captures the Platform adapter.
func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
return &Service{
@ -29,14 +26,12 @@ func Register(p Platform) func(*core.Core) (any, error) {
}
}
// OnStartup registers IPC handlers.
func (s *Service) OnStartup(ctx context.Context) error {
s.Core().RegisterQuery(s.handleQuery)
s.Core().RegisterTask(s.handleTask)
return nil
}
// HandleIPCEvents is auto-discovered by core.WithService.
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}
@ -54,7 +49,7 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
switch t := t.(type) {
case TaskSend:
return nil, true, s.send(t.Opts)
return nil, true, s.send(t.Options)
case TaskRequestPermission:
granted, err := s.platform.RequestPermission()
return granted, true, err
@ -64,24 +59,24 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
}
// send attempts native notification, falls back to dialog via IPC.
func (s *Service) send(opts NotificationOptions) error {
func (s *Service) send(options NotificationOptions) error {
// Generate ID if not provided
if opts.ID == "" {
opts.ID = fmt.Sprintf("core-%d", time.Now().UnixNano())
if options.ID == "" {
options.ID = "core-" + strconv.FormatInt(time.Now().UnixNano(), 10)
}
if err := s.platform.Send(opts); err != nil {
if err := s.platform.Send(options); err != nil {
// Fallback: show as dialog via IPC
return s.fallbackDialog(opts)
return s.fallbackDialog(options)
}
return nil
}
// fallbackDialog shows a dialog via IPC when native notifications fail.
func (s *Service) fallbackDialog(opts NotificationOptions) error {
func (s *Service) fallbackDialog(options NotificationOptions) error {
// Map severity to dialog type
var dt dialog.DialogType
switch opts.Severity {
switch options.Severity {
case SeverityWarning:
dt = dialog.DialogWarning
case SeverityError:
@ -90,15 +85,15 @@ func (s *Service) fallbackDialog(opts NotificationOptions) error {
dt = dialog.DialogInfo
}
msg := opts.Message
if opts.Subtitle != "" {
msg = opts.Subtitle + "\n\n" + msg
msg := options.Message
if options.Subtitle != "" {
msg = options.Subtitle + "\n\n" + msg
}
_, _, err := s.Core().PERFORM(dialog.TaskMessageDialog{
Opts: dialog.MessageDialogOptions{
Options: dialog.MessageDialogOptions{
Type: dt,
Title: opts.Title,
Title: options.Title,
Message: msg,
Buttons: []string{"OK"},
},

View file

@ -66,7 +66,7 @@ func TestRegister_Good(t *testing.T) {
func TestTaskSend_Good(t *testing.T) {
mock, c := newTestService(t)
_, handled, err := c.PERFORM(TaskSend{
Opts: NotificationOptions{Title: "Test", Message: "Hello"},
Options: NotificationOptions{Title: "Test", Message: "Hello"},
})
require.NoError(t, err)
assert.True(t, handled)
@ -87,7 +87,7 @@ func TestTaskSend_Fallback_Good(t *testing.T) {
require.NoError(t, c.ServiceStartup(context.Background(), nil))
_, handled, err := c.PERFORM(TaskSend{
Opts: NotificationOptions{Title: "Warn", Message: "Oops", Severity: SeverityWarning},
Options: NotificationOptions{Title: "Warn", Message: "Oops", Severity: SeverityWarning},
})
assert.True(t, handled)
assert.NoError(t, err) // fallback succeeds even though platform failed

View file

@ -1,31 +1,29 @@
// pkg/systray/menu.go
package systray
import "fmt"
import coreerr "forge.lthn.ai/core/go-log"
// SetMenu sets a dynamic menu on the tray from TrayMenuItem descriptors.
func (m *Manager) SetMenu(items []TrayMenuItem) error {
if m.tray == nil {
return fmt.Errorf("tray not initialised")
return coreerr.E("systray.Manager.SetMenu", "tray not initialised", nil)
}
menu := m.buildMenu(items)
menu := m.platform.NewMenu()
m.buildMenu(menu, items)
m.tray.SetMenu(menu)
return nil
}
// buildMenu recursively builds a PlatformMenu from TrayMenuItem descriptors.
func (m *Manager) buildMenu(items []TrayMenuItem) PlatformMenu {
menu := m.platform.NewMenu()
func (m *Manager) buildMenu(menu PlatformMenu, items []TrayMenuItem) {
for _, item := range items {
if item.Type == "separator" {
menu.AddSeparator()
continue
}
if len(item.Submenu) > 0 {
sub := m.buildMenu(item.Submenu)
mi := menu.Add(item.Label)
_ = mi.AddSubmenu()
_ = sub // TODO: wire sub into parent via platform
sub := menu.AddSubmenu(item.Label)
m.buildMenu(sub, item.Submenu)
continue
}
mi := menu.Add(item.Label)
@ -47,7 +45,6 @@ func (m *Manager) buildMenu(items []TrayMenuItem) PlatformMenu {
})
}
}
return menu
}
// RegisterCallback registers a callback for a menu action ID.

View file

@ -1,30 +1,17 @@
package systray
// QueryConfig requests this service's config section from the display orchestrator.
// Result: map[string]any
type QueryConfig struct{}
// --- Tasks ---
// TaskSetTrayIcon sets the tray icon.
type TaskSetTrayIcon struct{ Data []byte }
// TaskSetTrayMenu sets the tray menu items.
type TaskSetTrayMenu struct{ Items []TrayMenuItem }
// TaskShowPanel shows the tray panel window.
type TaskShowPanel struct{}
// TaskHidePanel hides the tray panel window.
type TaskHidePanel struct{}
// TaskSaveConfig persists this service's config section via the display orchestrator.
type TaskSaveConfig struct{ Value map[string]any }
type TaskSaveConfig struct{ Config map[string]any }
// --- Actions ---
// ActionTrayClicked is broadcast when the tray icon is clicked.
type ActionTrayClicked struct{}
// ActionTrayMenuItemClicked is broadcast when a tray menu item is clicked.
type ActionTrayMenuItemClicked struct{ ActionID string }

View file

@ -13,14 +13,17 @@ type exportedMockTray struct {
tooltip, label string
}
func (t *exportedMockTray) SetIcon(data []byte) { t.icon = data }
func (t *exportedMockTray) SetTemplateIcon(data []byte) { t.templateIcon = data }
func (t *exportedMockTray) SetTooltip(text string) { t.tooltip = text }
func (t *exportedMockTray) SetLabel(text string) { t.label = text }
func (t *exportedMockTray) SetMenu(menu PlatformMenu) {}
func (t *exportedMockTray) AttachWindow(w WindowHandle) {}
func (t *exportedMockTray) SetIcon(data []byte) { t.icon = data }
func (t *exportedMockTray) SetTemplateIcon(data []byte) { t.templateIcon = data }
func (t *exportedMockTray) SetTooltip(text string) { t.tooltip = text }
func (t *exportedMockTray) SetLabel(text string) { t.label = text }
func (t *exportedMockTray) SetMenu(menu PlatformMenu) {}
func (t *exportedMockTray) AttachWindow(w WindowHandle) {}
type exportedMockMenu struct{ items []exportedMockMenuItem }
type exportedMockMenu struct {
items []exportedMockMenuItem
subs []*exportedMockMenu
}
func (m *exportedMockMenu) Add(label string) PlatformMenuItem {
mi := &exportedMockMenuItem{label: label}
@ -28,15 +31,20 @@ func (m *exportedMockMenu) Add(label string) PlatformMenuItem {
return mi
}
func (m *exportedMockMenu) AddSeparator() {}
func (m *exportedMockMenu) AddSubmenu(label string) PlatformMenu {
m.items = append(m.items, exportedMockMenuItem{label: label})
sub := &exportedMockMenu{}
m.subs = append(m.subs, sub)
return sub
}
type exportedMockMenuItem struct {
label, tooltip string
label, tooltip string
checked, enabled bool
onClick func()
}
func (mi *exportedMockMenuItem) SetTooltip(tip string) { mi.tooltip = tip }
func (mi *exportedMockMenuItem) SetChecked(checked bool) { mi.checked = checked }
func (mi *exportedMockMenuItem) SetEnabled(enabled bool) { mi.enabled = enabled }
func (mi *exportedMockMenuItem) OnClick(fn func()) { mi.onClick = fn }
func (mi *exportedMockMenuItem) AddSubmenu() PlatformMenu { return &exportedMockMenu{} }
func (mi *exportedMockMenuItem) SetTooltip(tip string) { mi.tooltip = tip }
func (mi *exportedMockMenuItem) SetChecked(checked bool) { mi.checked = checked }
func (mi *exportedMockMenuItem) SetEnabled(enabled bool) { mi.enabled = enabled }
func (mi *exportedMockMenuItem) OnClick(fn func()) { mi.onClick = fn }

View file

@ -22,6 +22,7 @@ func (p *mockPlatform) NewMenu() PlatformMenu {
type mockTrayMenu struct {
items []string
subs []*mockTrayMenu
}
func (m *mockTrayMenu) Add(label string) PlatformMenuItem {
@ -29,14 +30,19 @@ func (m *mockTrayMenu) Add(label string) PlatformMenuItem {
return &mockTrayMenuItem{}
}
func (m *mockTrayMenu) AddSeparator() { m.items = append(m.items, "---") }
func (m *mockTrayMenu) AddSubmenu(label string) PlatformMenu {
m.items = append(m.items, label)
sub := &mockTrayMenu{}
m.subs = append(m.subs, sub)
return sub
}
type mockTrayMenuItem struct{}
func (mi *mockTrayMenuItem) SetTooltip(text string) {}
func (mi *mockTrayMenuItem) SetChecked(checked bool) {}
func (mi *mockTrayMenuItem) SetEnabled(enabled bool) {}
func (mi *mockTrayMenuItem) OnClick(fn func()) {}
func (mi *mockTrayMenuItem) AddSubmenu() PlatformMenu { return &mockTrayMenu{} }
func (mi *mockTrayMenuItem) SetChecked(checked bool) {}
func (mi *mockTrayMenuItem) SetEnabled(enabled bool) {}
func (mi *mockTrayMenuItem) OnClick(fn func()) {}
type mockTray struct {
icon, templateIcon []byte
@ -45,9 +51,9 @@ type mockTray struct {
attachedWindow WindowHandle
}
func (t *mockTray) SetIcon(data []byte) { t.icon = data }
func (t *mockTray) SetIcon(data []byte) { t.icon = data }
func (t *mockTray) SetTemplateIcon(data []byte) { t.templateIcon = data }
func (t *mockTray) SetTooltip(text string) { t.tooltip = text }
func (t *mockTray) SetLabel(text string) { t.label = text }
func (t *mockTray) SetMenu(menu PlatformMenu) { t.menu = menu }
func (t *mockTray) AttachWindow(w WindowHandle) { t.attachedWindow = w }
func (t *mockTray) AttachWindow(w WindowHandle) { t.attachedWindow = w }

View file

@ -21,6 +21,7 @@ type PlatformTray interface {
type PlatformMenu interface {
Add(label string) PlatformMenuItem
AddSeparator()
AddSubmenu(label string) PlatformMenu
}
// PlatformMenuItem is a single item in a tray menu.
@ -29,7 +30,6 @@ type PlatformMenuItem interface {
SetChecked(checked bool)
SetEnabled(enabled bool)
OnClick(fn func())
AddSubmenu() PlatformMenu
}
// WindowHandle is a cross-package interface for window operations.

View file

@ -2,7 +2,6 @@ package systray
import "forge.lthn.ai/core/go/pkg/core"
// Register creates a factory closure that captures the Platform adapter.
func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
return &Service{

View file

@ -6,10 +6,8 @@ import (
"forge.lthn.ai/core/go/pkg/core"
)
// Options holds configuration for the systray service.
type Options struct{}
// Service is a core.Service managing the system tray via IPC.
type Service struct {
*core.ServiceRuntime[Options]
manager *Manager
@ -17,33 +15,31 @@ type Service struct {
iconPath string
}
// OnStartup queries config and registers IPC handlers.
func (s *Service) OnStartup(ctx context.Context) error {
cfg, handled, _ := s.Core().QUERY(QueryConfig{})
configValue, handled, _ := s.Core().QUERY(QueryConfig{})
if handled {
if tCfg, ok := cfg.(map[string]any); ok {
s.applyConfig(tCfg)
if trayConfig, ok := configValue.(map[string]any); ok {
s.applyConfig(trayConfig)
}
}
s.Core().RegisterTask(s.handleTask)
return nil
}
func (s *Service) applyConfig(cfg map[string]any) {
tooltip, _ := cfg["tooltip"].(string)
func (s *Service) applyConfig(configData map[string]any) {
tooltip, _ := configData["tooltip"].(string)
if tooltip == "" {
tooltip = "Core"
}
_ = s.manager.Setup(tooltip, tooltip)
if iconPath, ok := cfg["icon"].(string); ok && iconPath != "" {
if iconPath, ok := configData["icon"].(string); ok && iconPath != "" {
// Icon loading is deferred to when assets are available.
// Store the path for later use.
s.iconPath = iconPath
}
}
// HandleIPCEvents is auto-discovered and registered by core.WithService.
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}
@ -78,7 +74,6 @@ func (s *Service) taskSetTrayMenu(t TaskSetTrayMenu) error {
return s.manager.SetMenu(t.Items)
}
// Manager returns the underlying systray Manager.
func (s *Service) Manager() *Manager {
return s.manager
}

View file

@ -3,8 +3,9 @@ package systray
import (
_ "embed"
"fmt"
"sync"
coreerr "forge.lthn.ai/core/go-log"
)
//go:embed assets/apptray.png
@ -31,7 +32,7 @@ func NewManager(platform Platform) *Manager {
func (m *Manager) Setup(tooltip, label string) error {
m.tray = m.platform.NewTray()
if m.tray == nil {
return fmt.Errorf("platform returned nil tray")
return coreerr.E("systray.Setup", "platform returned nil tray", nil)
}
m.tray.SetTemplateIcon(defaultIcon)
m.tray.SetTooltip(tooltip)
@ -42,7 +43,7 @@ func (m *Manager) Setup(tooltip, label string) error {
// SetIcon sets the tray icon.
func (m *Manager) SetIcon(data []byte) error {
if m.tray == nil {
return fmt.Errorf("tray not initialised")
return coreerr.E("systray.SetIcon", "tray not initialised", nil)
}
m.tray.SetIcon(data)
return nil
@ -51,7 +52,7 @@ func (m *Manager) SetIcon(data []byte) error {
// SetTemplateIcon sets the template icon (macOS).
func (m *Manager) SetTemplateIcon(data []byte) error {
if m.tray == nil {
return fmt.Errorf("tray not initialised")
return coreerr.E("systray.SetTemplateIcon", "tray not initialised", nil)
}
m.tray.SetTemplateIcon(data)
return nil
@ -60,7 +61,7 @@ func (m *Manager) SetTemplateIcon(data []byte) error {
// SetTooltip sets the tray tooltip.
func (m *Manager) SetTooltip(text string) error {
if m.tray == nil {
return fmt.Errorf("tray not initialised")
return coreerr.E("systray.SetTooltip", "tray not initialised", nil)
}
m.tray.SetTooltip(text)
return nil
@ -69,7 +70,7 @@ func (m *Manager) SetTooltip(text string) error {
// SetLabel sets the tray label.
func (m *Manager) SetLabel(text string) error {
if m.tray == nil {
return fmt.Errorf("tray not initialised")
return coreerr.E("systray.SetLabel", "tray not initialised", nil)
}
m.tray.SetLabel(text)
return nil
@ -78,7 +79,7 @@ func (m *Manager) SetLabel(text string) error {
// AttachWindow attaches a panel window to the tray.
func (m *Manager) AttachWindow(w WindowHandle) error {
if m.tray == nil {
return fmt.Errorf("tray not initialised")
return coreerr.E("systray.AttachWindow", "tray not initialised", nil)
}
m.tray.AttachWindow(w)
return nil

View file

@ -84,3 +84,29 @@ func TestManager_GetInfo_Good(t *testing.T) {
info = m.GetInfo()
assert.True(t, info["active"].(bool))
}
func TestManager_Build_Submenu_Recursive_Good(t *testing.T) {
m, p := newTestManager()
require.NoError(t, m.Setup("Core", "Core"))
items := []TrayMenuItem{
{
Label: "Parent",
Submenu: []TrayMenuItem{
{Label: "Child 1"},
{Label: "Child 2"},
},
},
}
require.NoError(t, m.SetMenu(items))
require.Len(t, p.menus, 1)
menu := p.menus[0]
require.Len(t, menu.items, 1)
assert.Equal(t, "Parent", menu.items[0])
require.Len(t, menu.subs, 1)
require.Len(t, menu.subs[0].items, 2)
assert.Equal(t, "Child 1", menu.subs[0].items[0])
assert.Equal(t, "Child 2", menu.subs[0].items[1])
}

View file

@ -28,9 +28,9 @@ type wailsTray struct {
}
func (wt *wailsTray) SetIcon(data []byte) { wt.tray.SetIcon(data) }
func (wt *wailsTray) SetTemplateIcon(data []byte) { wt.tray.SetTemplateIcon(data) }
func (wt *wailsTray) SetTooltip(text string) { wt.tray.SetTooltip(text) }
func (wt *wailsTray) SetLabel(text string) { wt.tray.SetLabel(text) }
func (wt *wailsTray) SetTemplateIcon(data []byte) { wt.tray.SetTemplateIcon(data) }
func (wt *wailsTray) SetTooltip(text string) { wt.tray.SetTooltip(text) }
func (wt *wailsTray) SetLabel(text string) { wt.tray.SetLabel(text) }
func (wt *wailsTray) SetMenu(menu PlatformMenu) {
if wm, ok := menu.(*wailsTrayMenu); ok {
@ -56,18 +56,18 @@ func (m *wailsTrayMenu) AddSeparator() {
m.menu.AddSeparator()
}
func (m *wailsTrayMenu) AddSubmenu(label string) PlatformMenu {
return &wailsTrayMenu{menu: m.menu.AddSubmenu(label)}
}
// wailsTrayMenuItem wraps *application.MenuItem for the PlatformMenuItem interface.
type wailsTrayMenuItem struct {
item *application.MenuItem
}
func (mi *wailsTrayMenuItem) SetTooltip(text string) { mi.item.SetTooltip(text) }
func (mi *wailsTrayMenuItem) SetTooltip(text string) { mi.item.SetTooltip(text) }
func (mi *wailsTrayMenuItem) SetChecked(checked bool) { mi.item.SetChecked(checked) }
func (mi *wailsTrayMenuItem) SetEnabled(enabled bool) { mi.item.SetEnabled(enabled) }
func (mi *wailsTrayMenuItem) OnClick(fn func()) {
mi.item.OnClick(func(ctx *application.Context) { fn() })
}
func (mi *wailsTrayMenuItem) AddSubmenu() PlatformMenu {
// Wails doesn't have a direct AddSubmenu on MenuItem — use Menu.AddSubmenu instead
return &wailsTrayMenu{menu: application.NewMenu()}
}

View file

@ -2,10 +2,10 @@
package webview
import (
"bytes"
"context"
"encoding/base64"
"strconv"
"strings"
"sync"
"time"
@ -47,66 +47,73 @@ type Options struct {
// Service is a core.Service managing webview interactions via IPC.
type Service struct {
*core.ServiceRuntime[Options]
opts Options
options Options
connections map[string]connector
mu sync.RWMutex
newConn func(debugURL, windowName string) (connector, error) // injectable for tests
watcherSetup func(conn connector, windowName string) // called after connection creation
}
// Register creates a factory closure with the given options.
func Register(opts ...func(*Options)) func(*core.Core) (any, error) {
o := Options{
DebugURL: "http://localhost:9222",
Timeout: 30 * time.Second,
ConsoleLimit: 1000,
}
for _, fn := range opts {
fn(&o)
}
// Build a Core factory from one declarative Options value.
// factory := webview.Register(webview.Options{DebugURL: "http://localhost:9333"})
func Register(options Options) func(*core.Core) (any, error) {
options = defaultOptions(options)
return func(c *core.Core) (any, error) {
svc := &Service{
ServiceRuntime: core.NewServiceRuntime[Options](c, o),
opts: o,
ServiceRuntime: core.NewServiceRuntime[Options](c, options),
options: options,
connections: make(map[string]connector),
newConn: defaultNewConn(o),
newConn: defaultNewConn(options),
}
svc.watcherSetup = svc.defaultWatcherSetup
return svc, nil
}
}
func defaultOptions(options Options) Options {
if options.DebugURL == "" {
options.DebugURL = "http://localhost:9222"
}
if options.Timeout <= 0 {
options.Timeout = 30 * time.Second
}
if options.ConsoleLimit <= 0 {
options.ConsoleLimit = 1000
}
return options
}
// defaultNewConn creates real go-webview connections.
func defaultNewConn(opts Options) func(string, string) (connector, error) {
func defaultNewConn(options Options) func(string, string) (connector, error) {
return func(debugURL, windowName string) (connector, error) {
// Enumerate targets, match by title/URL containing window name
targets, err := gowebview.ListTargets(debugURL)
if err != nil {
return nil, err
}
var wsURL string
var webSocketURL string
for _, t := range targets {
if t.Type == "page" && (strings.Contains(t.Title, windowName) || strings.Contains(t.URL, windowName)) {
wsURL = t.WebSocketDebuggerURL
if t.Type == "page" && (bytes.Contains([]byte(t.Title), []byte(windowName)) || bytes.Contains([]byte(t.URL), []byte(windowName))) {
webSocketURL = t.WebSocketDebuggerURL
break
}
}
// Fallback: first page target
if wsURL == "" {
if webSocketURL == "" {
for _, t := range targets {
if t.Type == "page" {
wsURL = t.WebSocketDebuggerURL
webSocketURL = t.WebSocketDebuggerURL
break
}
}
}
if wsURL == "" {
if webSocketURL == "" {
return nil, core.E("webview.connect", "no page target found", nil)
}
wv, err := gowebview.New(
gowebview.WithDebugURL(debugURL),
gowebview.WithTimeout(opts.Timeout),
gowebview.WithConsoleLimit(opts.ConsoleLimit),
gowebview.WithTimeout(options.Timeout),
gowebview.WithConsoleLimit(options.ConsoleLimit),
)
if err != nil {
return nil, err
@ -123,8 +130,8 @@ func (s *Service) defaultWatcherSetup(conn connector, windowName string) {
return // test mocks don't need watchers
}
cw := gowebview.NewConsoleWatcher(rc.wv)
cw.AddHandler(func(msg gowebview.ConsoleMessage) {
consoleWatcher := gowebview.NewConsoleWatcher(rc.wv)
consoleWatcher.AddHandler(func(msg gowebview.ConsoleMessage) {
_ = s.Core().ACTION(ActionConsoleMessage{
Window: windowName,
Message: ConsoleMessage{
@ -138,8 +145,8 @@ func (s *Service) defaultWatcherSetup(conn connector, windowName string) {
})
})
ew := gowebview.NewExceptionWatcher(rc.wv)
ew.AddHandler(func(exc gowebview.ExceptionInfo) {
exceptionWatcher := gowebview.NewExceptionWatcher(rc.wv)
exceptionWatcher.AddHandler(func(exc gowebview.ExceptionInfo) {
_ = s.Core().ACTION(ActionException{
Window: windowName,
Exception: ExceptionInfo{
@ -201,7 +208,7 @@ func (s *Service) getConn(windowName string) (connector, error) {
if conn, ok := s.connections[windowName]; ok {
return conn, nil
}
conn, err := s.newConn(s.opts.DebugURL, windowName)
conn, err := s.newConn(s.options.DebugURL, windowName)
if err != nil {
return nil, err
}
@ -373,17 +380,17 @@ type realConnector struct {
wv *gowebview.Webview
}
func (r *realConnector) Navigate(url string) error { return r.wv.Navigate(url) }
func (r *realConnector) Click(sel string) error { return r.wv.Click(sel) }
func (r *realConnector) Type(sel, text string) error { return r.wv.Type(sel, text) }
func (r *realConnector) Evaluate(script string) (any, error) { return r.wv.Evaluate(script) }
func (r *realConnector) Screenshot() ([]byte, error) { return r.wv.Screenshot() }
func (r *realConnector) GetURL() (string, error) { return r.wv.GetURL() }
func (r *realConnector) GetTitle() (string, error) { return r.wv.GetTitle() }
func (r *realConnector) GetHTML(sel string) (string, error) { return r.wv.GetHTML(sel) }
func (r *realConnector) ClearConsole() { r.wv.ClearConsole() }
func (r *realConnector) Close() error { return r.wv.Close() }
func (r *realConnector) SetViewport(w, h int) error { return r.wv.SetViewport(w, h) }
func (r *realConnector) Navigate(url string) error { return r.wv.Navigate(url) }
func (r *realConnector) Click(sel string) error { return r.wv.Click(sel) }
func (r *realConnector) Type(sel, text string) error { return r.wv.Type(sel, text) }
func (r *realConnector) Evaluate(script string) (any, error) { return r.wv.Evaluate(script) }
func (r *realConnector) Screenshot() ([]byte, error) { return r.wv.Screenshot() }
func (r *realConnector) GetURL() (string, error) { return r.wv.GetURL() }
func (r *realConnector) GetTitle() (string, error) { return r.wv.GetTitle() }
func (r *realConnector) GetHTML(sel string) (string, error) { return r.wv.GetHTML(sel) }
func (r *realConnector) ClearConsole() { r.wv.ClearConsole() }
func (r *realConnector) Close() error { return r.wv.Close() }
func (r *realConnector) SetViewport(w, h int) error { return r.wv.SetViewport(w, h) }
func (r *realConnector) UploadFile(sel string, p []string) error { return r.wv.UploadFile(sel, p) }
func (r *realConnector) Hover(sel string) error {

View file

@ -4,6 +4,7 @@ package webview
import (
"context"
"testing"
"time"
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/gui/pkg/window"
@ -37,21 +38,41 @@ type mockConnector struct {
consoleClearCalled bool
}
func (m *mockConnector) Navigate(url string) error { m.lastNavURL = url; return nil }
func (m *mockConnector) Click(sel string) error { m.lastClickSel = sel; return nil }
func (m *mockConnector) Type(sel, text string) error { m.lastTypeSel = sel; m.lastTypeText = text; return nil }
func (m *mockConnector) Hover(sel string) error { m.lastHoverSel = sel; return nil }
func (m *mockConnector) Select(sel, val string) error { m.lastSelectSel = sel; m.lastSelectVal = val; return nil }
func (m *mockConnector) Check(sel string, c bool) error { m.lastCheckSel = sel; m.lastCheckVal = c; return nil }
func (m *mockConnector) Evaluate(s string) (any, error) { return m.evalResult, nil }
func (m *mockConnector) Screenshot() ([]byte, error) { return m.screenshot, nil }
func (m *mockConnector) GetURL() (string, error) { return m.url, nil }
func (m *mockConnector) GetTitle() (string, error) { return m.title, nil }
func (m *mockConnector) Navigate(url string) error { m.lastNavURL = url; return nil }
func (m *mockConnector) Click(sel string) error { m.lastClickSel = sel; return nil }
func (m *mockConnector) Type(sel, text string) error {
m.lastTypeSel = sel
m.lastTypeText = text
return nil
}
func (m *mockConnector) Hover(sel string) error { m.lastHoverSel = sel; return nil }
func (m *mockConnector) Select(sel, val string) error {
m.lastSelectSel = sel
m.lastSelectVal = val
return nil
}
func (m *mockConnector) Check(sel string, c bool) error {
m.lastCheckSel = sel
m.lastCheckVal = c
return nil
}
func (m *mockConnector) Evaluate(s string) (any, error) { return m.evalResult, nil }
func (m *mockConnector) Screenshot() ([]byte, error) { return m.screenshot, nil }
func (m *mockConnector) GetURL() (string, error) { return m.url, nil }
func (m *mockConnector) GetTitle() (string, error) { return m.title, nil }
func (m *mockConnector) GetHTML(sel string) (string, error) { return m.html, nil }
func (m *mockConnector) ClearConsole() { m.consoleClearCalled = true }
func (m *mockConnector) Close() error { m.closed = true; return nil }
func (m *mockConnector) SetViewport(w, h int) error { m.lastViewportW = w; m.lastViewportH = h; return nil }
func (m *mockConnector) UploadFile(sel string, p []string) error { m.lastUploadSel = sel; m.lastUploadPaths = p; return nil }
func (m *mockConnector) ClearConsole() { m.consoleClearCalled = true }
func (m *mockConnector) Close() error { m.closed = true; return nil }
func (m *mockConnector) SetViewport(w, h int) error {
m.lastViewportW = w
m.lastViewportH = h
return nil
}
func (m *mockConnector) UploadFile(sel string, p []string) error {
m.lastUploadSel = sel
m.lastUploadPaths = p
return nil
}
func (m *mockConnector) QuerySelector(sel string) (*ElementInfo, error) {
if len(m.elements) > 0 {
@ -68,7 +89,7 @@ func (m *mockConnector) GetConsole() []ConsoleMessage { return m.console }
func newTestService(t *testing.T, mock *mockConnector) (*Service, *core.Core) {
t.Helper()
factory := Register()
factory := Register(Options{})
c, err := core.New(core.WithService(factory), core.WithServiceLock())
require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
@ -83,6 +104,22 @@ func TestRegister_Good(t *testing.T) {
assert.NotNil(t, svc)
}
func TestRegister_Good_UsesOptions(t *testing.T) {
options := Options{
DebugURL: "http://localhost:9333",
Timeout: 45 * time.Second,
ConsoleLimit: 12,
}
factory := Register(options)
c, err := core.New(core.WithService(factory), core.WithServiceLock())
require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
svc := core.MustServiceFor[*Service](c, "webview")
assert.Equal(t, options, svc.options)
}
func TestQueryURL_Good(t *testing.T) {
_, c := newTestService(t, &mockConnector{url: "https://example.com"})
result, handled, err := c.QUERY(QueryURL{Window: "main"})

View file

@ -3,11 +3,14 @@ package window
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"sync"
"time"
coreio "forge.lthn.ai/core/go-io"
coreerr "forge.lthn.ai/core/go-log"
)
// Layout is a named window arrangement.
@ -65,13 +68,13 @@ func (lm *LayoutManager) load() {
if lm.configDir == "" {
return
}
data, err := os.ReadFile(lm.filePath())
content, err := coreio.Local.Read(lm.filePath())
if err != nil {
return
}
lm.mu.Lock()
defer lm.mu.Unlock()
_ = json.Unmarshal(data, &lm.layouts)
_ = json.Unmarshal([]byte(content), &lm.layouts)
}
func (lm *LayoutManager) save() {
@ -84,14 +87,14 @@ func (lm *LayoutManager) save() {
if err != nil {
return
}
_ = os.MkdirAll(lm.configDir, 0o755)
_ = os.WriteFile(lm.filePath(), data, 0o644)
_ = coreio.Local.EnsureDir(lm.configDir)
_ = coreio.Local.Write(lm.filePath(), string(data))
}
// SaveLayout creates or updates a named layout.
func (lm *LayoutManager) SaveLayout(name string, windowStates map[string]WindowState) error {
if name == "" {
return fmt.Errorf("layout name cannot be empty")
return coreerr.E("window.LayoutManager.SaveLayout", "layout name cannot be empty", nil)
}
now := time.Now().UnixMilli()
lm.mu.Lock()
@ -131,6 +134,9 @@ func (lm *LayoutManager) ListLayouts() []LayoutInfo {
CreatedAt: l.CreatedAt, UpdatedAt: l.UpdatedAt,
})
}
sort.Slice(infos, func(i, j int) bool {
return infos[i].Name < infos[j].Name
})
return infos
}

View file

@ -1,6 +1,5 @@
package window
// WindowInfo contains information about a window.
type WindowInfo struct {
Name string `json:"name"`
Title string `json:"title"`
@ -8,107 +7,127 @@ type WindowInfo struct {
Y int `json:"y"`
Width int `json:"width"`
Height int `json:"height"`
Visible bool `json:"visible"`
Minimized bool `json:"minimized"`
Maximized bool `json:"maximized"`
Focused bool `json:"focused"`
}
// --- Queries (read-only) ---
// QueryWindowList returns all tracked windows. Result: []WindowInfo
type QueryWindowList struct{}
// QueryWindowByName returns a single window by name. Result: *WindowInfo (nil if not found)
type QueryWindowByName struct{ Name string }
// QueryConfig requests this service's config section from the display orchestrator.
// Result: map[string]any
type QueryConfig struct{}
// --- Tasks (side-effects) ---
type QuerySavedWindowStates struct{}
// TaskOpenWindow creates a new window. Result: WindowInfo
type TaskOpenWindow struct{ Opts []WindowOption }
// Example: c.PERFORM(TaskOpenWindow{Window: Window{Name: "editor", URL: "/"}})
type TaskOpenWindow struct {
Window Window
}
// TaskCloseWindow closes a window. Handler persists state BEFORE emitting ActionWindowClosed.
type TaskCloseWindow struct{ Name string }
// TaskSetPosition moves a window.
// Example: c.PERFORM(TaskSetPosition{Name: "editor", X: 100, Y: 200})
type TaskSetPosition struct {
Name string
X, Y int
}
// TaskSetSize resizes a window.
type TaskSetSize struct {
Name string
W, H int
// Example: c.PERFORM(TaskSetBounds{Name: "editor", X: 100, Y: 200, Width: 1280, Height: 720})
type TaskSetBounds struct {
Name string
X, Y int
Width, Height int
}
// TaskMaximise maximises a window.
type TaskMaximise struct{ Name string }
// Example: c.PERFORM(TaskSetSize{Name: "editor", Width: 1280, Height: 720})
type TaskSetSize struct {
Name string
Width, Height int
}
// TaskMinimise minimises a window.
type TaskMinimise struct{ Name string }
// Example: c.PERFORM(TaskMaximize{Name: "editor"})
type TaskMaximize struct{ Name string }
// Example: c.PERFORM(TaskMinimize{Name: "editor"})
type TaskMinimize struct{ Name string }
// TaskFocus brings a window to the front.
type TaskFocus struct{ Name string }
// TaskRestore restores a maximised or minimised window to its normal state.
type TaskRestore struct{ Name string }
// TaskSetTitle changes a window's title.
type TaskSetTitle struct {
Name string
Title string
}
// TaskSetVisibility shows or hides a window.
type TaskSetAlwaysOnTop struct {
Name string
AlwaysOnTop bool
}
type TaskSetBackgroundColour struct {
Name string
Red uint8
Green uint8
Blue uint8
Alpha uint8
}
type TaskSetVisibility struct {
Name string
Visible bool
}
// TaskFullscreen enters or exits fullscreen mode.
type TaskFullscreen struct {
Name string
Fullscreen bool
}
// --- Layout Queries ---
// QueryLayoutList returns summaries of all saved layouts. Result: []LayoutInfo
type QueryLayoutList struct{}
// QueryLayoutGet returns a layout by name. Result: *Layout (nil if not found)
type QueryLayoutGet struct{ Name string }
// --- Layout Tasks ---
// TaskSaveLayout saves the current window arrangement as a named layout. Result: bool
// Example: c.PERFORM(TaskSaveLayout{Name: "coding"})
type TaskSaveLayout struct{ Name string }
// TaskRestoreLayout restores a saved layout by name.
// Example: c.PERFORM(TaskRestoreLayout{Name: "coding"})
type TaskRestoreLayout struct{ Name string }
// TaskDeleteLayout removes a saved layout by name.
// Example: c.PERFORM(TaskDeleteLayout{Name: "coding"})
type TaskDeleteLayout struct{ Name string }
// TaskTileWindows arranges windows in a tiling mode.
// Example: c.PERFORM(TaskResetWindowState{})
type TaskResetWindowState struct{}
// Example: c.PERFORM(TaskTileWindows{Mode: "left-right", Windows: []string{"editor", "terminal"}})
type TaskTileWindows struct {
Mode string // "left-right", "grid", "left-half", "right-half", etc.
Windows []string // window names; empty = all
}
// TaskSnapWindow snaps a window to a screen edge/corner.
// Example: c.PERFORM(TaskStackWindows{Windows: []string{"editor", "terminal"}, OffsetX: 24, OffsetY: 24})
type TaskStackWindows struct {
Windows []string // window names; empty = all
OffsetX int
OffsetY int
}
// Example: c.PERFORM(TaskSnapWindow{Name: "editor", Position: "right"})
type TaskSnapWindow struct {
Name string // window name
Position string // "left", "right", "top", "bottom", "top-left", "top-right", "bottom-left", "bottom-right", "center"
}
// TaskSaveConfig persists this service's config section via the display orchestrator.
type TaskSaveConfig struct{ Value map[string]any }
// Example: c.PERFORM(TaskApplyWorkflow{Workflow: "coding"})
type TaskApplyWorkflow struct {
Workflow string
Windows []string // window names; empty = all
}
// --- Actions (broadcasts) ---
// Example: c.PERFORM(TaskSaveConfig{Config: map[string]any{"default_width": 800}})
type TaskSaveConfig struct{ Config map[string]any }
type ActionWindowOpened struct{ Name string }
type ActionWindowClosed struct{ Name string }
@ -119,15 +138,15 @@ type ActionWindowMoved struct {
}
type ActionWindowResized struct {
Name string
W, H int
Name string
Width, Height int
}
type ActionWindowFocused struct{ Name string }
type ActionWindowBlurred struct{ Name string }
type ActionFilesDropped struct {
Name string `json:"name"` // window name
Name string `json:"name"` // window name
Paths []string `json:"paths"`
TargetID string `json:"targetId,omitempty"`
}

View file

@ -10,11 +10,12 @@ func NewMockPlatform() *MockPlatform {
return &MockPlatform{}
}
func (m *MockPlatform) CreateWindow(opts PlatformWindowOptions) PlatformWindow {
func (m *MockPlatform) CreateWindow(options PlatformWindowOptions) PlatformWindow {
w := &MockWindow{
name: opts.Name, title: opts.Title, url: opts.URL,
width: opts.Width, height: opts.Height,
x: opts.X, y: opts.Y,
name: options.Name, title: options.Title, url: options.URL,
width: options.Width, height: options.Height,
x: options.X, y: options.Y,
visible: !options.Hidden,
}
m.Windows = append(m.Windows, w)
return w
@ -31,35 +32,49 @@ func (m *MockPlatform) GetWindows() []PlatformWindow {
type MockWindow struct {
name, title, url string
width, height, x, y int
maximised, focused bool
maximised, minimised bool
focused bool
visible, alwaysOnTop bool
backgroundColour [4]uint8
closed bool
eventHandlers []func(WindowEvent)
fileDropHandlers []func(paths []string, targetID string)
}
func (w *MockWindow) Name() string { return w.name }
func (w *MockWindow) Title() string { return w.title }
func (w *MockWindow) Position() (int, int) { return w.x, w.y }
func (w *MockWindow) Size() (int, int) { return w.width, w.height }
func (w *MockWindow) IsMaximised() bool { return w.maximised }
func (w *MockWindow) IsFocused() bool { return w.focused }
func (w *MockWindow) SetTitle(title string) { w.title = title }
func (w *MockWindow) SetPosition(x, y int) { w.x = x; w.y = y }
func (w *MockWindow) SetSize(width, height int) { w.width = width; w.height = height }
func (w *MockWindow) SetBackgroundColour(r, g, b, a uint8) {}
func (w *MockWindow) SetVisibility(visible bool) { w.visible = visible }
func (w *MockWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop }
func (w *MockWindow) Maximise() { w.maximised = true }
func (w *MockWindow) Restore() { w.maximised = false }
func (w *MockWindow) Minimise() {}
func (w *MockWindow) Focus() { w.focused = true }
func (w *MockWindow) Close() { w.closed = true }
func (w *MockWindow) Show() { w.visible = true }
func (w *MockWindow) Hide() { w.visible = false }
func (w *MockWindow) Fullscreen() {}
func (w *MockWindow) UnFullscreen() {}
func (w *MockWindow) OnWindowEvent(handler func(WindowEvent)) { w.eventHandlers = append(w.eventHandlers, handler) }
func (w *MockWindow) Name() string { return w.name }
func (w *MockWindow) Title() string { return w.title }
func (w *MockWindow) Position() (int, int) { return w.x, w.y }
func (w *MockWindow) Size() (int, int) { return w.width, w.height }
func (w *MockWindow) IsMaximised() bool { return w.maximised }
func (w *MockWindow) IsMinimised() bool { return w.minimised }
func (w *MockWindow) IsVisible() bool { return w.visible }
func (w *MockWindow) IsFocused() bool { return w.focused }
func (w *MockWindow) SetTitle(title string) { w.title = title }
func (w *MockWindow) SetBounds(x, y, width, height int) {
w.x, w.y, w.width, w.height = x, y, width, height
}
func (w *MockWindow) SetPosition(x, y int) { w.x = x; w.y = y }
func (w *MockWindow) SetSize(width, height int) { w.width = width; w.height = height }
func (w *MockWindow) SetBackgroundColour(r, g, b, a uint8) { w.backgroundColour = [4]uint8{r, g, b, a} }
func (w *MockWindow) SetVisibility(visible bool) { w.visible = visible }
func (w *MockWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop }
func (w *MockWindow) Maximise() { w.maximised = true; w.minimised = false }
func (w *MockWindow) Restore() { w.maximised = false; w.minimised = false }
func (w *MockWindow) Minimise() { w.maximised = false; w.minimised = true }
func (w *MockWindow) Focus() { w.focused = true }
func (w *MockWindow) Close() {
w.closed = true
for _, handler := range w.eventHandlers {
handler(WindowEvent{Type: "close", Name: w.name})
}
}
func (w *MockWindow) Show() { w.visible = true }
func (w *MockWindow) Hide() { w.visible = false }
func (w *MockWindow) Fullscreen() {}
func (w *MockWindow) UnFullscreen() {}
func (w *MockWindow) OnWindowEvent(handler func(WindowEvent)) {
w.eventHandlers = append(w.eventHandlers, handler)
}
func (w *MockWindow) OnFileDrop(handler func(paths []string, targetID string)) {
w.fileDropHandlers = append(w.fileDropHandlers, handler)
}

View file

@ -1,4 +1,3 @@
// pkg/window/mock_test.go
package window
type mockPlatform struct {
@ -9,11 +8,12 @@ func newMockPlatform() *mockPlatform {
return &mockPlatform{}
}
func (m *mockPlatform) CreateWindow(opts PlatformWindowOptions) PlatformWindow {
func (m *mockPlatform) CreateWindow(options PlatformWindowOptions) PlatformWindow {
w := &mockWindow{
name: opts.Name, title: opts.Title, url: opts.URL,
width: opts.Width, height: opts.Height,
x: opts.X, y: opts.Y,
name: options.Name, title: options.Title, url: options.URL,
width: options.Width, height: options.Height,
x: options.X, y: options.Y,
visible: !options.Hidden,
}
m.windows = append(m.windows, w)
return w
@ -30,35 +30,48 @@ func (m *mockPlatform) GetWindows() []PlatformWindow {
type mockWindow struct {
name, title, url string
width, height, x, y int
maximised, focused bool
maximised, minimised bool
focused bool
visible, alwaysOnTop bool
backgroundColour [4]uint8
closed bool
fullscreened bool
eventHandlers []func(WindowEvent)
fileDropHandlers []func(paths []string, targetID string)
}
func (w *mockWindow) Name() string { return w.name }
func (w *mockWindow) Title() string { return w.title }
func (w *mockWindow) Position() (int, int) { return w.x, w.y }
func (w *mockWindow) Size() (int, int) { return w.width, w.height }
func (w *mockWindow) IsMaximised() bool { return w.maximised }
func (w *mockWindow) IsFocused() bool { return w.focused }
func (w *mockWindow) SetTitle(title string) { w.title = title }
func (w *mockWindow) SetPosition(x, y int) { w.x = x; w.y = y }
func (w *mockWindow) SetSize(width, height int) { w.width = width; w.height = height }
func (w *mockWindow) SetBackgroundColour(r, g, b, a uint8) {}
func (w *mockWindow) SetVisibility(visible bool) { w.visible = visible }
func (w *mockWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop }
func (w *mockWindow) Maximise() { w.maximised = true }
func (w *mockWindow) Restore() { w.maximised = false }
func (w *mockWindow) Minimise() {}
func (w *mockWindow) Focus() { w.focused = true }
func (w *mockWindow) Close() { w.closed = true }
func (w *mockWindow) Show() { w.visible = true }
func (w *mockWindow) Hide() { w.visible = false }
func (w *mockWindow) Fullscreen() {}
func (w *mockWindow) UnFullscreen() {}
func (w *mockWindow) OnWindowEvent(handler func(WindowEvent)) { w.eventHandlers = append(w.eventHandlers, handler) }
func (w *mockWindow) Name() string { return w.name }
func (w *mockWindow) Title() string { return w.title }
func (w *mockWindow) Position() (int, int) { return w.x, w.y }
func (w *mockWindow) Size() (int, int) { return w.width, w.height }
func (w *mockWindow) IsMaximised() bool { return w.maximised }
func (w *mockWindow) IsMinimised() bool { return w.minimised }
func (w *mockWindow) IsVisible() bool { return w.visible }
func (w *mockWindow) IsFocused() bool { return w.focused }
func (w *mockWindow) SetTitle(title string) { w.title = title }
func (w *mockWindow) SetBounds(x, y, width, height int) {
w.x, w.y, w.width, w.height = x, y, width, height
}
func (w *mockWindow) SetPosition(x, y int) { w.x = x; w.y = y }
func (w *mockWindow) SetSize(width, height int) { w.width = width; w.height = height }
func (w *mockWindow) SetBackgroundColour(r, g, b, a uint8) { w.backgroundColour = [4]uint8{r, g, b, a} }
func (w *mockWindow) SetVisibility(visible bool) { w.visible = visible }
func (w *mockWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop }
func (w *mockWindow) Maximise() { w.maximised = true; w.minimised = false }
func (w *mockWindow) Restore() { w.maximised = false; w.minimised = false }
func (w *mockWindow) Minimise() { w.maximised = false; w.minimised = true }
func (w *mockWindow) Focus() { w.focused = true }
func (w *mockWindow) Close() {
w.closed = true
w.emit(WindowEvent{Type: "close", Name: w.name})
}
func (w *mockWindow) Show() { w.visible = true }
func (w *mockWindow) Hide() { w.visible = false }
func (w *mockWindow) Fullscreen() { w.fullscreened = true }
func (w *mockWindow) UnFullscreen() { w.fullscreened = false }
func (w *mockWindow) OnWindowEvent(handler func(WindowEvent)) {
w.eventHandlers = append(w.eventHandlers, handler)
}
func (w *mockWindow) OnFileDrop(handler func(paths []string, targetID string)) {
w.fileDropHandlers = append(w.fileDropHandlers, handler)
}

View file

@ -1,67 +0,0 @@
// pkg/window/options.go
package window
// WindowOption is a functional option applied to a Window descriptor.
type WindowOption func(*Window) error
// ApplyOptions creates a Window and applies all options in order.
func ApplyOptions(opts ...WindowOption) (*Window, error) {
w := &Window{}
for _, opt := range opts {
if opt == nil {
continue
}
if err := opt(w); err != nil {
return nil, err
}
}
return w, nil
}
func WithName(name string) WindowOption {
return func(w *Window) error { w.Name = name; return nil }
}
func WithTitle(title string) WindowOption {
return func(w *Window) error { w.Title = title; return nil }
}
func WithURL(url string) WindowOption {
return func(w *Window) error { w.URL = url; return nil }
}
func WithSize(width, height int) WindowOption {
return func(w *Window) error { w.Width = width; w.Height = height; return nil }
}
func WithPosition(x, y int) WindowOption {
return func(w *Window) error { w.X = x; w.Y = y; return nil }
}
func WithMinSize(width, height int) WindowOption {
return func(w *Window) error { w.MinWidth = width; w.MinHeight = height; return nil }
}
func WithMaxSize(width, height int) WindowOption {
return func(w *Window) error { w.MaxWidth = width; w.MaxHeight = height; return nil }
}
func WithFrameless(frameless bool) WindowOption {
return func(w *Window) error { w.Frameless = frameless; return nil }
}
func WithHidden(hidden bool) WindowOption {
return func(w *Window) error { w.Hidden = hidden; return nil }
}
func WithAlwaysOnTop(alwaysOnTop bool) WindowOption {
return func(w *Window) error { w.AlwaysOnTop = alwaysOnTop; return nil }
}
func WithBackgroundColour(r, g, b, a uint8) WindowOption {
return func(w *Window) error { w.BackgroundColour = [4]uint8{r, g, b, a}; return nil }
}
func WithFileDrop(enabled bool) WindowOption {
return func(w *Window) error { w.EnableFileDrop = enabled; return nil }
}

View file

@ -0,0 +1,375 @@
// pkg/window/persistence_test.go
package window
import (
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// --- StateManager Persistence Tests ---
func TestStateManager_SetAndGet_Good(t *testing.T) {
sm := NewStateManagerWithDir(t.TempDir())
state := WindowState{
X: 150, Y: 250, Width: 1024, Height: 768,
Maximized: true, Screen: "primary", URL: "/app",
}
sm.SetState("editor", state)
got, ok := sm.GetState("editor")
require.True(t, ok)
assert.Equal(t, 150, got.X)
assert.Equal(t, 250, got.Y)
assert.Equal(t, 1024, got.Width)
assert.Equal(t, 768, got.Height)
assert.True(t, got.Maximized)
assert.Equal(t, "primary", got.Screen)
assert.Equal(t, "/app", got.URL)
assert.NotZero(t, got.UpdatedAt, "UpdatedAt should be set by SetState")
}
func TestStateManager_UpdatePosition_Good(t *testing.T) {
sm := NewStateManagerWithDir(t.TempDir())
sm.SetState("win", WindowState{X: 0, Y: 0, Width: 800, Height: 600})
sm.UpdatePosition("win", 300, 400)
got, ok := sm.GetState("win")
require.True(t, ok)
assert.Equal(t, 300, got.X)
assert.Equal(t, 400, got.Y)
// Width/Height should remain unchanged
assert.Equal(t, 800, got.Width)
assert.Equal(t, 600, got.Height)
}
func TestStateManager_UpdateSize_Good(t *testing.T) {
sm := NewStateManagerWithDir(t.TempDir())
sm.SetState("win", WindowState{X: 100, Y: 200, Width: 800, Height: 600})
sm.UpdateSize("win", 1920, 1080)
got, ok := sm.GetState("win")
require.True(t, ok)
assert.Equal(t, 1920, got.Width)
assert.Equal(t, 1080, got.Height)
// Position should remain unchanged
assert.Equal(t, 100, got.X)
assert.Equal(t, 200, got.Y)
}
func TestStateManager_UpdateBounds_Good(t *testing.T) {
sm := NewStateManagerWithDir(t.TempDir())
sm.SetState("win", WindowState{X: 100, Y: 200, Width: 800, Height: 600})
sm.UpdateBounds("win", 300, 400, 1280, 720)
got, ok := sm.GetState("win")
require.True(t, ok)
assert.Equal(t, 300, got.X)
assert.Equal(t, 400, got.Y)
assert.Equal(t, 1280, got.Width)
assert.Equal(t, 720, got.Height)
}
func TestStateManager_UpdateMaximized_Good(t *testing.T) {
sm := NewStateManagerWithDir(t.TempDir())
sm.SetState("win", WindowState{Width: 800, Height: 600, Maximized: false})
sm.UpdateMaximized("win", true)
got, ok := sm.GetState("win")
require.True(t, ok)
assert.True(t, got.Maximized)
sm.UpdateMaximized("win", false)
got, ok = sm.GetState("win")
require.True(t, ok)
assert.False(t, got.Maximized)
}
func TestStateManager_CaptureState_Good(t *testing.T) {
sm := NewStateManagerWithDir(t.TempDir())
pw := &mockWindow{
name: "captured", x: 75, y: 125,
width: 1440, height: 900, maximised: true,
}
sm.CaptureState(pw)
got, ok := sm.GetState("captured")
require.True(t, ok)
assert.Equal(t, 75, got.X)
assert.Equal(t, 125, got.Y)
assert.Equal(t, 1440, got.Width)
assert.Equal(t, 900, got.Height)
assert.True(t, got.Maximized)
assert.NotZero(t, got.UpdatedAt)
}
func TestStateManager_CaptureState_PreservesMetadata_Good(t *testing.T) {
sm := NewStateManagerWithDir(t.TempDir())
sm.SetState("captured", WindowState{
Screen: "primary",
URL: "/app",
Width: 640,
Height: 480,
})
pw := &mockWindow{
name: "captured", x: 75, y: 125,
width: 1440, height: 900, maximised: true,
}
sm.CaptureState(pw)
got, ok := sm.GetState("captured")
require.True(t, ok)
assert.Equal(t, "primary", got.Screen)
assert.Equal(t, "/app", got.URL)
assert.Equal(t, 75, got.X)
assert.Equal(t, 125, got.Y)
assert.Equal(t, 1440, got.Width)
assert.Equal(t, 900, got.Height)
assert.True(t, got.Maximized)
}
func TestStateManager_ApplyState_Good(t *testing.T) {
sm := NewStateManagerWithDir(t.TempDir())
sm.SetState("target", WindowState{X: 55, Y: 65, Width: 700, Height: 500})
w := &Window{Name: "target", Width: 1280, Height: 800, X: 0, Y: 0}
sm.ApplyState(w)
assert.Equal(t, 55, w.X)
assert.Equal(t, 65, w.Y)
assert.Equal(t, 700, w.Width)
assert.Equal(t, 500, w.Height)
}
func TestStateManager_ApplyState_NoState(t *testing.T) {
sm := NewStateManagerWithDir(t.TempDir())
w := &Window{Name: "untouched", Width: 1280, Height: 800, X: 10, Y: 20}
sm.ApplyState(w)
// Window should remain unchanged when no state is saved
assert.Equal(t, 10, w.X)
assert.Equal(t, 20, w.Y)
assert.Equal(t, 1280, w.Width)
assert.Equal(t, 800, w.Height)
}
func TestStateManager_ListStates_Good(t *testing.T) {
sm := NewStateManagerWithDir(t.TempDir())
sm.SetState("alpha", WindowState{Width: 100})
sm.SetState("beta", WindowState{Width: 200})
sm.SetState("gamma", WindowState{Width: 300})
names := sm.ListStates()
assert.Len(t, names, 3)
assert.Contains(t, names, "alpha")
assert.Contains(t, names, "beta")
assert.Contains(t, names, "gamma")
}
func TestStateManager_Clear_Good(t *testing.T) {
sm := NewStateManagerWithDir(t.TempDir())
sm.SetState("a", WindowState{Width: 100})
sm.SetState("b", WindowState{Width: 200})
sm.SetState("c", WindowState{Width: 300})
sm.Clear()
names := sm.ListStates()
assert.Empty(t, names)
_, ok := sm.GetState("a")
assert.False(t, ok)
}
func TestStateManager_Persistence_Good(t *testing.T) {
dir := t.TempDir()
// First manager: write state and force sync to disk
sm1 := NewStateManagerWithDir(dir)
sm1.SetState("persist-win", WindowState{
X: 42, Y: 84, Width: 500, Height: 300,
Maximized: true, Screen: "secondary", URL: "/settings",
})
sm1.ForceSync()
// Second manager: load from the same directory
sm2 := NewStateManagerWithDir(dir)
got, ok := sm2.GetState("persist-win")
require.True(t, ok)
assert.Equal(t, 42, got.X)
assert.Equal(t, 84, got.Y)
assert.Equal(t, 500, got.Width)
assert.Equal(t, 300, got.Height)
assert.True(t, got.Maximized)
assert.Equal(t, "secondary", got.Screen)
assert.Equal(t, "/settings", got.URL)
assert.NotZero(t, got.UpdatedAt)
}
func TestStateManager_SetPath_Good(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "custom", "window-state.json")
sm := NewStateManagerWithDir(dir)
sm.SetPath(path)
sm.SetState("custom", WindowState{Width: 640, Height: 480})
sm.ForceSync()
content, err := os.ReadFile(path)
require.NoError(t, err)
assert.Contains(t, string(content), "custom")
}
// --- LayoutManager Persistence Tests ---
func TestLayoutManager_SaveAndGet_Good(t *testing.T) {
lm := NewLayoutManagerWithDir(t.TempDir())
windows := map[string]WindowState{
"editor": {X: 0, Y: 0, Width: 960, Height: 1080},
"terminal": {X: 960, Y: 0, Width: 960, Height: 540},
"browser": {X: 960, Y: 540, Width: 960, Height: 540},
}
err := lm.SaveLayout("coding", windows)
require.NoError(t, err)
layout, ok := lm.GetLayout("coding")
require.True(t, ok)
assert.Equal(t, "coding", layout.Name)
assert.Len(t, layout.Windows, 3)
assert.Equal(t, 960, layout.Windows["editor"].Width)
assert.Equal(t, 1080, layout.Windows["editor"].Height)
assert.Equal(t, 960, layout.Windows["terminal"].X)
assert.NotZero(t, layout.CreatedAt)
assert.NotZero(t, layout.UpdatedAt)
assert.Equal(t, layout.CreatedAt, layout.UpdatedAt, "CreatedAt and UpdatedAt should match on first save")
}
func TestLayoutManager_SaveLayout_EmptyName_Bad(t *testing.T) {
lm := NewLayoutManagerWithDir(t.TempDir())
err := lm.SaveLayout("", map[string]WindowState{
"win": {Width: 800},
})
assert.Error(t, err)
}
func TestLayoutManager_SaveLayout_Update_Good(t *testing.T) {
lm := NewLayoutManagerWithDir(t.TempDir())
// First save
err := lm.SaveLayout("evolving", map[string]WindowState{
"win1": {Width: 800, Height: 600},
})
require.NoError(t, err)
first, ok := lm.GetLayout("evolving")
require.True(t, ok)
originalCreatedAt := first.CreatedAt
originalUpdatedAt := first.UpdatedAt
// Small delay to ensure UpdatedAt differs
time.Sleep(2 * time.Millisecond)
// Second save with same name but different windows
err = lm.SaveLayout("evolving", map[string]WindowState{
"win1": {Width: 1024, Height: 768},
"win2": {Width: 640, Height: 480},
})
require.NoError(t, err)
updated, ok := lm.GetLayout("evolving")
require.True(t, ok)
// CreatedAt should be preserved from the original save
assert.Equal(t, originalCreatedAt, updated.CreatedAt, "CreatedAt should be preserved on update")
// UpdatedAt should be newer
assert.GreaterOrEqual(t, updated.UpdatedAt, originalUpdatedAt, "UpdatedAt should advance on update")
// Windows should reflect the second save
assert.Len(t, updated.Windows, 2)
assert.Equal(t, 1024, updated.Windows["win1"].Width)
}
func TestLayoutManager_ListLayouts_Good(t *testing.T) {
lm := NewLayoutManagerWithDir(t.TempDir())
require.NoError(t, lm.SaveLayout("coding", map[string]WindowState{
"editor": {Width: 960}, "terminal": {Width: 960},
}))
require.NoError(t, lm.SaveLayout("presenting", map[string]WindowState{
"slides": {Width: 1920},
}))
require.NoError(t, lm.SaveLayout("debugging", map[string]WindowState{
"code": {Width: 640}, "debugger": {Width: 640}, "console": {Width: 640},
}))
infos := lm.ListLayouts()
assert.Len(t, infos, 3)
// Build a lookup map for assertions regardless of order
byName := make(map[string]LayoutInfo)
for _, info := range infos {
byName[info.Name] = info
}
assert.Equal(t, 2, byName["coding"].WindowCount)
assert.Equal(t, 1, byName["presenting"].WindowCount)
assert.Equal(t, 3, byName["debugging"].WindowCount)
}
func TestLayoutManager_DeleteLayout_Good(t *testing.T) {
lm := NewLayoutManagerWithDir(t.TempDir())
require.NoError(t, lm.SaveLayout("temporary", map[string]WindowState{
"win": {Width: 800},
}))
// Verify it exists
_, ok := lm.GetLayout("temporary")
require.True(t, ok)
lm.DeleteLayout("temporary")
// Verify it is gone
_, ok = lm.GetLayout("temporary")
assert.False(t, ok)
// Verify list is empty
assert.Empty(t, lm.ListLayouts())
}
func TestLayoutManager_Persistence_Good(t *testing.T) {
dir := t.TempDir()
// First manager: save layout to disk
lm1 := NewLayoutManagerWithDir(dir)
err := lm1.SaveLayout("persisted", map[string]WindowState{
"main": {X: 0, Y: 0, Width: 1280, Height: 800},
"sidebar": {X: 1280, Y: 0, Width: 640, Height: 800},
})
require.NoError(t, err)
// Second manager: load from the same directory
lm2 := NewLayoutManagerWithDir(dir)
layout, ok := lm2.GetLayout("persisted")
require.True(t, ok)
assert.Equal(t, "persisted", layout.Name)
assert.Len(t, layout.Windows, 2)
assert.Equal(t, 1280, layout.Windows["main"].Width)
assert.Equal(t, 800, layout.Windows["main"].Height)
assert.Equal(t, 640, layout.Windows["sidebar"].Width)
assert.NotZero(t, layout.CreatedAt)
assert.NotZero(t, layout.UpdatedAt)
}

View file

@ -3,25 +3,25 @@ package window
// Platform abstracts the windowing backend (Wails v3).
type Platform interface {
CreateWindow(opts PlatformWindowOptions) PlatformWindow
CreateWindow(options PlatformWindowOptions) PlatformWindow
GetWindows() []PlatformWindow
}
// PlatformWindowOptions are the backend-specific options passed to CreateWindow.
type PlatformWindowOptions struct {
Name string
Title string
URL string
Width, Height int
X, Y int
MinWidth, MinHeight int
MaxWidth, MaxHeight int
Frameless bool
Hidden bool
AlwaysOnTop bool
BackgroundColour [4]uint8 // RGBA
DisableResize bool
EnableFileDrop bool
Name string
Title string
URL string
Width, Height int
X, Y int
MinWidth, MinHeight int
MaxWidth, MaxHeight int
Frameless bool
Hidden bool
AlwaysOnTop bool
BackgroundColour [4]uint8 // RGBA
DisableResize bool
EnableFileDrop bool
}
// PlatformWindow is a live window handle from the backend.
@ -34,10 +34,13 @@ type PlatformWindow interface {
Position() (int, int)
Size() (int, int)
IsMaximised() bool
IsMinimised() bool
IsVisible() bool
IsFocused() bool
// Mutations
SetTitle(title string)
SetBounds(x, y, width, height int)
SetPosition(x, y int)
SetSize(width, height int)
SetBackgroundColour(r, g, b, a uint8)

View file

@ -2,8 +2,6 @@ package window
import "forge.lthn.ai/core/go/pkg/core"
// Register creates a factory closure that captures the Platform adapter.
// The returned function has the signature WithService requires: func(*Core) (any, error).
func Register(p Platform) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
return &Service{

View file

@ -2,72 +2,73 @@ package window
import (
"context"
"fmt"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/gui/pkg/screen"
)
// Options holds configuration for the window service.
type Options struct{}
// Service is a core.Service managing window lifecycle via IPC.
// It embeds ServiceRuntime for Core access and composes Manager for platform operations.
type Service struct {
*core.ServiceRuntime[Options]
manager *Manager
platform Platform
}
// OnStartup queries config from the display orchestrator and registers IPC handlers.
func (s *Service) OnStartup(ctx context.Context) error {
// Query config display registers its handler before us (registration order guarantee).
// Query config - display registers its handler before us (registration order guarantee).
// If display is not registered, handled=false and we skip config.
cfg, handled, _ := s.Core().QUERY(QueryConfig{})
configValue, handled, _ := s.Core().QUERY(QueryConfig{})
if handled {
if wCfg, ok := cfg.(map[string]any); ok {
s.applyConfig(wCfg)
if windowConfig, ok := configValue.(map[string]any); ok {
s.applyConfig(windowConfig)
}
}
// Register QUERY and TASK handlers manually.
// ACTION handler (HandleIPCEvents) is auto-registered by WithService —
// do NOT call RegisterAction here or actions will double-fire.
s.Core().RegisterQuery(s.handleQuery)
s.Core().RegisterTask(s.handleTask)
return nil
}
func (s *Service) applyConfig(cfg map[string]any) {
if w, ok := cfg["default_width"]; ok {
if _, ok := w.(int); ok {
// TODO: s.manager.SetDefaultWidth(width) — add when Manager API is extended
func (s *Service) applyConfig(configData map[string]any) {
if width, ok := configData["default_width"]; ok {
if width, ok := width.(int); ok {
s.manager.SetDefaultWidth(width)
}
}
if h, ok := cfg["default_height"]; ok {
if _, ok := h.(int); ok {
// TODO: s.manager.SetDefaultHeight(height) — add when Manager API is extended
if height, ok := configData["default_height"]; ok {
if height, ok := height.(int); ok {
s.manager.SetDefaultHeight(height)
}
}
if sf, ok := cfg["state_file"]; ok {
if _, ok := sf.(string); ok {
// TODO: s.manager.State().SetPath(stateFile) — add when StateManager API is extended
if stateFile, ok := configData["state_file"]; ok {
if stateFile, ok := stateFile.(string); ok {
s.manager.State().SetPath(stateFile)
}
}
}
// HandleIPCEvents is auto-discovered and registered by core.WithService.
func (s *Service) requireWindow(name string, operation string) (PlatformWindow, error) {
platformWindow, ok := s.manager.Get(name)
if !ok {
return nil, coreerr.E(operation, "window not found: "+name, nil)
}
return platformWindow, nil
}
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}
// --- Query Handlers ---
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
switch q := q.(type) {
case QueryWindowList:
return s.queryWindowList(), true, nil
case QueryWindowByName:
return s.queryWindowByName(q.Name), true, nil
case QuerySavedWindowStates:
return s.querySavedWindowStates(), true, nil
case QueryLayoutList:
return s.manager.Layout().ListLayouts(), true, nil
case QueryLayoutGet:
@ -85,13 +86,20 @@ func (s *Service) queryWindowList() []WindowInfo {
names := s.manager.List()
result := make([]WindowInfo, 0, len(names))
for _, name := range names {
if pw, ok := s.manager.Get(name); ok {
x, y := pw.Position()
w, h := pw.Size()
if platformWindow, ok := s.manager.Get(name); ok {
x, y := platformWindow.Position()
width, height := platformWindow.Size()
result = append(result, WindowInfo{
Name: name, Title: pw.Title(), X: x, Y: y, Width: w, Height: h,
Maximized: pw.IsMaximised(),
Focused: pw.IsFocused(),
Name: name,
Title: platformWindow.Title(),
X: x,
Y: y,
Width: width,
Height: height,
Visible: platformWindow.IsVisible(),
Minimized: platformWindow.IsMinimised(),
Maximized: platformWindow.IsMaximised(),
Focused: platformWindow.IsFocused(),
})
}
}
@ -99,19 +107,37 @@ func (s *Service) queryWindowList() []WindowInfo {
}
func (s *Service) queryWindowByName(name string) *WindowInfo {
pw, ok := s.manager.Get(name)
platformWindow, ok := s.manager.Get(name)
if !ok {
return nil
}
x, y := pw.Position()
w, h := pw.Size()
x, y := platformWindow.Position()
width, height := platformWindow.Size()
return &WindowInfo{
Name: name, Title: pw.Title(), X: x, Y: y, Width: w, Height: h,
Maximized: pw.IsMaximised(),
Focused: pw.IsFocused(),
Name: name,
Title: platformWindow.Title(),
X: x,
Y: y,
Width: width,
Height: height,
Visible: platformWindow.IsVisible(),
Minimized: platformWindow.IsMinimised(),
Maximized: platformWindow.IsMaximised(),
Focused: platformWindow.IsFocused(),
}
}
func (s *Service) querySavedWindowStates() map[string]WindowState {
stateNames := s.manager.State().ListStates()
result := make(map[string]WindowState, len(stateNames))
for _, name := range stateNames {
if state, ok := s.manager.State().GetState(name); ok {
result[name] = state
}
}
return result
}
// --- Task Handlers ---
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
@ -122,18 +148,24 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
return nil, true, s.taskCloseWindow(t.Name)
case TaskSetPosition:
return nil, true, s.taskSetPosition(t.Name, t.X, t.Y)
case TaskSetBounds:
return nil, true, s.taskSetBounds(t.Name, t.X, t.Y, t.Width, t.Height)
case TaskSetSize:
return nil, true, s.taskSetSize(t.Name, t.W, t.H)
case TaskMaximise:
return nil, true, s.taskMaximise(t.Name)
case TaskMinimise:
return nil, true, s.taskMinimise(t.Name)
return nil, true, s.taskSetSize(t.Name, t.Width, t.Height)
case TaskMaximize:
return nil, true, s.taskMaximize(t.Name)
case TaskMinimize:
return nil, true, s.taskMinimize(t.Name)
case TaskFocus:
return nil, true, s.taskFocus(t.Name)
case TaskRestore:
return nil, true, s.taskRestore(t.Name)
case TaskSetTitle:
return nil, true, s.taskSetTitle(t.Name, t.Title)
case TaskSetAlwaysOnTop:
return nil, true, s.taskSetAlwaysOnTop(t.Name, t.AlwaysOnTop)
case TaskSetBackgroundColour:
return nil, true, s.taskSetBackgroundColour(t.Name, t.Red, t.Green, t.Blue, t.Alpha)
case TaskSetVisibility:
return nil, true, s.taskSetVisibility(t.Name, t.Visible)
case TaskFullscreen:
@ -145,59 +177,110 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
case TaskDeleteLayout:
s.manager.Layout().DeleteLayout(t.Name)
return nil, true, nil
case TaskResetWindowState:
s.manager.State().Clear()
return nil, true, nil
case TaskTileWindows:
return nil, true, s.taskTileWindows(t.Mode, t.Windows)
case TaskStackWindows:
return nil, true, s.taskStackWindows(t.Windows, t.OffsetX, t.OffsetY)
case TaskSnapWindow:
return nil, true, s.taskSnapWindow(t.Name, t.Position)
case TaskApplyWorkflow:
return nil, true, s.taskApplyWorkflow(t.Workflow, t.Windows)
default:
return nil, false, nil
}
}
func (s *Service) primaryScreenArea() (int, int, int, int) {
const fallbackX = 0
const fallbackY = 0
const fallbackWidth = 1920
const fallbackHeight = 1080
result, handled, err := s.Core().QUERY(screen.QueryPrimary{})
if err != nil || !handled {
return fallbackX, fallbackY, fallbackWidth, fallbackHeight
}
primary, ok := result.(*screen.Screen)
if !ok || primary == nil {
return fallbackX, fallbackY, fallbackWidth, fallbackHeight
}
x := primary.WorkArea.X
y := primary.WorkArea.Y
width := primary.WorkArea.Width
height := primary.WorkArea.Height
if width <= 0 || height <= 0 {
x = primary.Bounds.X
y = primary.Bounds.Y
width = primary.Bounds.Width
height = primary.Bounds.Height
}
if width <= 0 || height <= 0 {
return fallbackX, fallbackY, fallbackWidth, fallbackHeight
}
return x, y, width, height
}
func (s *Service) taskOpenWindow(t TaskOpenWindow) (any, bool, error) {
pw, err := s.manager.Open(t.Opts...)
platformWindow, err := s.manager.Create(t.Window)
if err != nil {
return nil, true, err
}
x, y := pw.Position()
w, h := pw.Size()
info := WindowInfo{Name: pw.Name(), Title: pw.Title(), X: x, Y: y, Width: w, Height: h}
x, y := platformWindow.Position()
width, height := platformWindow.Size()
info := WindowInfo{
Name: platformWindow.Name(),
Title: platformWindow.Title(),
X: x,
Y: y,
Width: width,
Height: height,
Visible: platformWindow.IsVisible(),
Minimized: platformWindow.IsMinimised(),
Maximized: platformWindow.IsMaximised(),
Focused: platformWindow.IsFocused(),
}
// Attach platform event listeners that convert to IPC actions
s.trackWindow(pw)
s.trackWindow(platformWindow)
// Broadcast to all listeners
_ = s.Core().ACTION(ActionWindowOpened{Name: pw.Name()})
_ = s.Core().ACTION(ActionWindowOpened{Name: platformWindow.Name()})
return info, true, nil
}
// trackWindow attaches platform event listeners that emit IPC actions.
func (s *Service) trackWindow(pw PlatformWindow) {
pw.OnWindowEvent(func(e WindowEvent) {
switch e.Type {
func (s *Service) trackWindow(platformWindow PlatformWindow) {
platformWindow.OnWindowEvent(func(event WindowEvent) {
switch event.Type {
case "focus":
_ = s.Core().ACTION(ActionWindowFocused{Name: e.Name})
_ = s.Core().ACTION(ActionWindowFocused{Name: event.Name})
case "blur":
_ = s.Core().ACTION(ActionWindowBlurred{Name: e.Name})
_ = s.Core().ACTION(ActionWindowBlurred{Name: event.Name})
case "move":
if data := e.Data; data != nil {
if data := event.Data; data != nil {
x, _ := data["x"].(int)
y, _ := data["y"].(int)
_ = s.Core().ACTION(ActionWindowMoved{Name: e.Name, X: x, Y: y})
_ = s.Core().ACTION(ActionWindowMoved{Name: event.Name, X: x, Y: y})
}
case "resize":
if data := e.Data; data != nil {
w, _ := data["w"].(int)
h, _ := data["h"].(int)
_ = s.Core().ACTION(ActionWindowResized{Name: e.Name, W: w, H: h})
if data := event.Data; data != nil {
width, _ := data["width"].(int)
height, _ := data["height"].(int)
_ = s.Core().ACTION(ActionWindowResized{Name: event.Name, Width: width, Height: height})
}
case "close":
_ = s.Core().ACTION(ActionWindowClosed{Name: e.Name})
_ = s.Core().ACTION(ActionWindowClosed{Name: event.Name})
}
})
pw.OnFileDrop(func(paths []string, targetID string) {
platformWindow.OnFileDrop(func(paths []string, targetID string) {
_ = s.Core().ACTION(ActionFilesDropped{
Name: pw.Name(),
Name: platformWindow.Name(),
Paths: paths,
TargetID: targetID,
})
@ -205,103 +288,130 @@ func (s *Service) trackWindow(pw PlatformWindow) {
}
func (s *Service) taskCloseWindow(name string) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
platformWindow, err := s.requireWindow(name, "window.Service.taskCloseWindow")
if err != nil {
return err
}
// Persist state BEFORE closing (spec requirement)
s.manager.State().CaptureState(pw)
pw.Close()
s.manager.State().CaptureState(platformWindow)
platformWindow.Close()
s.manager.Remove(name)
_ = s.Core().ACTION(ActionWindowClosed{Name: name})
return nil
}
func (s *Service) taskSetPosition(name string, x, y int) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
platformWindow, err := s.requireWindow(name, "window.Service.taskSetPosition")
if err != nil {
return err
}
pw.SetPosition(x, y)
platformWindow.SetPosition(x, y)
s.manager.State().UpdatePosition(name, x, y)
return nil
}
func (s *Service) taskSetSize(name string, w, h int) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
func (s *Service) taskSetBounds(name string, x, y, width, height int) error {
platformWindow, err := s.requireWindow(name, "window.Service.taskSetBounds")
if err != nil {
return err
}
pw.SetSize(w, h)
s.manager.State().UpdateSize(name, w, h)
platformWindow.SetBounds(x, y, width, height)
s.manager.State().UpdateBounds(name, x, y, width, height)
return nil
}
func (s *Service) taskMaximise(name string) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
func (s *Service) taskSetSize(name string, width, height int) error {
platformWindow, err := s.requireWindow(name, "window.Service.taskSetSize")
if err != nil {
return err
}
pw.Maximise()
platformWindow.SetSize(width, height)
s.manager.State().UpdateSize(name, width, height)
return nil
}
func (s *Service) taskMaximize(name string) error {
platformWindow, err := s.requireWindow(name, "window.Service.taskMaximize")
if err != nil {
return err
}
platformWindow.Maximise()
s.manager.State().UpdateMaximized(name, true)
return nil
}
func (s *Service) taskMinimise(name string) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
func (s *Service) taskMinimize(name string) error {
platformWindow, err := s.requireWindow(name, "window.Service.taskMinimize")
if err != nil {
return err
}
pw.Minimise()
platformWindow.Minimise()
return nil
}
func (s *Service) taskFocus(name string) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
platformWindow, err := s.requireWindow(name, "window.Service.taskFocus")
if err != nil {
return err
}
pw.Focus()
platformWindow.Focus()
return nil
}
func (s *Service) taskRestore(name string) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
platformWindow, err := s.requireWindow(name, "window.Service.taskRestore")
if err != nil {
return err
}
pw.Restore()
platformWindow.Restore()
s.manager.State().UpdateMaximized(name, false)
return nil
}
func (s *Service) taskSetTitle(name, title string) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
platformWindow, err := s.requireWindow(name, "window.Service.taskSetTitle")
if err != nil {
return err
}
pw.SetTitle(title)
platformWindow.SetTitle(title)
return nil
}
func (s *Service) taskSetAlwaysOnTop(name string, alwaysOnTop bool) error {
platformWindow, err := s.requireWindow(name, "window.Service.taskSetAlwaysOnTop")
if err != nil {
return err
}
platformWindow.SetAlwaysOnTop(alwaysOnTop)
return nil
}
func (s *Service) taskSetBackgroundColour(name string, red, green, blue, alpha uint8) error {
platformWindow, err := s.requireWindow(name, "window.Service.taskSetBackgroundColour")
if err != nil {
return err
}
platformWindow.SetBackgroundColour(red, green, blue, alpha)
return nil
}
func (s *Service) taskSetVisibility(name string, visible bool) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
platformWindow, err := s.requireWindow(name, "window.Service.taskSetVisibility")
if err != nil {
return err
}
pw.SetVisibility(visible)
platformWindow.SetVisibility(visible)
return nil
}
func (s *Service) taskFullscreen(name string, fullscreen bool) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
platformWindow, err := s.requireWindow(name, "window.Service.taskFullscreen")
if err != nil {
return err
}
if fullscreen {
pw.Fullscreen()
platformWindow.Fullscreen()
} else {
pw.UnFullscreen()
platformWindow.UnFullscreen()
}
return nil
}
@ -321,18 +431,20 @@ func (s *Service) taskSaveLayout(name string) error {
func (s *Service) taskRestoreLayout(name string) error {
layout, ok := s.manager.Layout().GetLayout(name)
if !ok {
return fmt.Errorf("layout not found: %s", name)
return coreerr.E("window.Service.taskRestoreLayout", "layout not found: "+name, nil)
}
for winName, state := range layout.Windows {
pw, found := s.manager.Get(winName)
platformWindow, found := s.manager.Get(winName)
if !found {
continue
}
pw.SetPosition(state.X, state.Y)
pw.SetSize(state.Width, state.Height)
platformWindow.SetBounds(state.X, state.Y, state.Width, state.Height)
if state.Maximized {
pw.Maximise()
platformWindow.Maximise()
} else {
platformWindow.Restore()
}
s.manager.State().CaptureState(platformWindow)
}
return nil
}
@ -348,13 +460,21 @@ var tileModeMap = map[string]TileMode{
func (s *Service) taskTileWindows(mode string, names []string) error {
tm, ok := tileModeMap[mode]
if !ok {
return fmt.Errorf("unknown tile mode: %s", mode)
return coreerr.E("window.Service.taskTileWindows", "unknown tile mode: "+mode, nil)
}
if len(names) == 0 {
names = s.manager.List()
}
// Default screen size — callers can query screen_primary for actual values.
return s.manager.TileWindows(tm, names, 1920, 1080)
originX, originY, screenWidth, screenHeight := s.primaryScreenArea()
return s.manager.TileWindows(tm, names, screenWidth, screenHeight, originX, originY)
}
func (s *Service) taskStackWindows(names []string, offsetX, offsetY int) error {
if len(names) == 0 {
names = s.manager.List()
}
originX, originY, _, _ := s.primaryScreenArea()
return s.manager.StackWindows(names, offsetX, offsetY, originX, originY)
}
var snapPosMap = map[string]SnapPosition{
@ -362,15 +482,35 @@ var snapPosMap = map[string]SnapPosition{
"top": SnapTop, "bottom": SnapBottom,
"top-left": SnapTopLeft, "top-right": SnapTopRight,
"bottom-left": SnapBottomLeft, "bottom-right": SnapBottomRight,
"center": SnapCenter, "centre": SnapCenter,
"center": SnapCenter,
}
func (s *Service) taskSnapWindow(name, position string) error {
pos, ok := snapPosMap[position]
if !ok {
return fmt.Errorf("unknown snap position: %s", position)
return coreerr.E("window.Service.taskSnapWindow", "unknown snap position: "+position, nil)
}
return s.manager.SnapWindow(name, pos, 1920, 1080)
originX, originY, screenWidth, screenHeight := s.primaryScreenArea()
return s.manager.SnapWindow(name, pos, screenWidth, screenHeight, originX, originY)
}
var workflowLayoutMap = map[string]WorkflowLayout{
"coding": WorkflowCoding,
"debugging": WorkflowDebugging,
"presenting": WorkflowPresenting,
"side-by-side": WorkflowSideBySide,
}
func (s *Service) taskApplyWorkflow(workflow string, names []string) error {
layout, ok := workflowLayoutMap[workflow]
if !ok {
return coreerr.E("window.Service.taskApplyWorkflow", "unknown workflow layout: "+workflow, nil)
}
if len(names) == 0 {
names = s.manager.List()
}
originX, originY, screenWidth, screenHeight := s.primaryScreenArea()
return s.manager.ApplyWorkflow(layout, names, screenWidth, screenHeight, originX, originY)
}
// Manager returns the underlying window Manager for direct access.

View file

@ -0,0 +1,177 @@
package window
import (
"context"
"testing"
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/gui/pkg/screen"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type mockScreenPlatform struct {
screens []screen.Screen
}
func (m *mockScreenPlatform) GetAll() []screen.Screen { return m.screens }
func (m *mockScreenPlatform) GetPrimary() *screen.Screen {
for i := range m.screens {
if m.screens[i].IsPrimary {
return &m.screens[i]
}
}
return nil
}
func newTestWindowServiceWithScreen(t *testing.T, screens []screen.Screen) (*Service, *core.Core) {
t.Helper()
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
c, err := core.New(
core.WithService(screen.Register(&mockScreenPlatform{screens: screens})),
core.WithService(Register(newMockPlatform())),
core.WithServiceLock(),
)
require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
svc := core.MustServiceFor[*Service](c, "window")
return svc, c
}
func TestTaskTileWindows_UsesPrimaryScreenSize(t *testing.T) {
_, c := newTestWindowServiceWithScreen(t, []screen.Screen{
{
ID: "1", Name: "Primary", IsPrimary: true,
Bounds: screen.Rect{X: 0, Y: 0, Width: 2000, Height: 1000},
WorkArea: screen.Rect{X: 0, Y: 0, Width: 2000, Height: 1000},
},
})
_, _, err := c.PERFORM(TaskOpenWindow{Window: Window{Name: "left", Width: 400, Height: 400}})
require.NoError(t, err)
_, _, err = c.PERFORM(TaskOpenWindow{Window: Window{Name: "right", Width: 400, Height: 400}})
require.NoError(t, err)
_, handled, err := c.PERFORM(TaskTileWindows{Mode: "left-right", Windows: []string{"left", "right"}})
require.NoError(t, err)
assert.True(t, handled)
result, _, err := c.QUERY(QueryWindowByName{Name: "left"})
require.NoError(t, err)
left := result.(*WindowInfo)
assert.Equal(t, 0, left.X)
assert.Equal(t, 1000, left.Width)
assert.Equal(t, 1000, left.Height)
result, _, err = c.QUERY(QueryWindowByName{Name: "right"})
require.NoError(t, err)
right := result.(*WindowInfo)
assert.Equal(t, 1000, right.X)
assert.Equal(t, 1000, right.Width)
assert.Equal(t, 1000, right.Height)
}
func TestTaskSnapWindow_UsesPrimaryScreenSize(t *testing.T) {
_, c := newTestWindowServiceWithScreen(t, []screen.Screen{
{
ID: "1", Name: "Primary", IsPrimary: true,
Bounds: screen.Rect{X: 0, Y: 0, Width: 2000, Height: 1000},
WorkArea: screen.Rect{X: 0, Y: 0, Width: 2000, Height: 1000},
},
})
_, _, err := c.PERFORM(TaskOpenWindow{Window: Window{Name: "snap", Width: 400, Height: 300}})
require.NoError(t, err)
_, handled, err := c.PERFORM(TaskSnapWindow{Name: "snap", Position: "left"})
require.NoError(t, err)
assert.True(t, handled)
result, _, err := c.QUERY(QueryWindowByName{Name: "snap"})
require.NoError(t, err)
info := result.(*WindowInfo)
assert.Equal(t, 0, info.X)
assert.Equal(t, 0, info.Y)
assert.Equal(t, 1000, info.Width)
assert.Equal(t, 1000, info.Height)
}
func TestTaskSnapWindow_Center_Good(t *testing.T) {
_, c := newTestWindowServiceWithScreen(t, []screen.Screen{
{
ID: "1", Name: "Primary", IsPrimary: true,
Bounds: screen.Rect{X: 0, Y: 0, Width: 2000, Height: 1000},
WorkArea: screen.Rect{X: 0, Y: 0, Width: 2000, Height: 1000},
},
})
_, _, err := c.PERFORM(TaskOpenWindow{Window: Window{Name: "snap", Width: 400, Height: 300}})
require.NoError(t, err)
_, handled, err := c.PERFORM(TaskSnapWindow{Name: "snap", Position: "center"})
require.NoError(t, err)
assert.True(t, handled)
result, _, err := c.QUERY(QueryWindowByName{Name: "snap"})
require.NoError(t, err)
info := result.(*WindowInfo)
assert.Equal(t, 800, info.X)
assert.Equal(t, 350, info.Y)
assert.Equal(t, 400, info.Width)
assert.Equal(t, 300, info.Height)
}
func TestTaskSnapWindow_LegacyCentre_Bad(t *testing.T) {
_, c := newTestWindowServiceWithScreen(t, []screen.Screen{
{
ID: "1", Name: "Primary", IsPrimary: true,
Bounds: screen.Rect{X: 0, Y: 0, Width: 2000, Height: 1000},
WorkArea: screen.Rect{X: 0, Y: 0, Width: 2000, Height: 1000},
},
})
_, _, err := c.PERFORM(TaskOpenWindow{Window: Window{Name: "snap", Width: 400, Height: 300}})
require.NoError(t, err)
_, handled, err := c.PERFORM(TaskSnapWindow{Name: "snap", Position: "centre"})
assert.True(t, handled)
assert.Error(t, err)
}
func TestTaskTileWindows_UsesPrimaryWorkAreaOrigin(t *testing.T) {
_, c := newTestWindowServiceWithScreen(t, []screen.Screen{
{
ID: "1", Name: "Primary", IsPrimary: true,
Bounds: screen.Rect{X: 0, Y: 0, Width: 2000, Height: 1000},
WorkArea: screen.Rect{X: 100, Y: 50, Width: 2000, Height: 1000},
},
})
_, _, err := c.PERFORM(TaskOpenWindow{Window: Window{Name: "left", Width: 400, Height: 400}})
require.NoError(t, err)
_, _, err = c.PERFORM(TaskOpenWindow{Window: Window{Name: "right", Width: 400, Height: 400}})
require.NoError(t, err)
_, handled, err := c.PERFORM(TaskTileWindows{Mode: "left-right", Windows: []string{"left", "right"}})
require.NoError(t, err)
assert.True(t, handled)
result, _, err := c.QUERY(QueryWindowByName{Name: "left"})
require.NoError(t, err)
left := result.(*WindowInfo)
assert.Equal(t, 100, left.X)
assert.Equal(t, 50, left.Y)
assert.Equal(t, 1000, left.Width)
assert.Equal(t, 1000, left.Height)
result, _, err = c.QUERY(QueryWindowByName{Name: "right"})
require.NoError(t, err)
right := result.(*WindowInfo)
assert.Equal(t, 1100, right.X)
assert.Equal(t, 50, right.Y)
assert.Equal(t, 1000, right.Width)
assert.Equal(t, 1000, right.Height)
}

View file

@ -12,6 +12,8 @@ import (
func newTestWindowService(t *testing.T) (*Service, *core.Core) {
t.Helper()
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
c, err := core.New(
core.WithService(Register(newMockPlatform())),
core.WithServiceLock(),
@ -31,12 +33,25 @@ func TestRegister_Good(t *testing.T) {
func TestTaskOpenWindow_Good(t *testing.T) {
_, c := newTestWindowService(t)
result, handled, err := c.PERFORM(TaskOpenWindow{
Opts: []WindowOption{WithName("test"), WithURL("/")},
Window: Window{Name: "test", URL: "/"},
})
require.NoError(t, err)
assert.True(t, handled)
info := result.(WindowInfo)
assert.Equal(t, "test", info.Name)
assert.True(t, info.Visible)
assert.False(t, info.Minimized)
}
func TestTaskOpenWindow_Declarative_Good(t *testing.T) {
_, c := newTestWindowService(t)
result, handled, err := c.PERFORM(TaskOpenWindow{
Window: Window{Name: "test-fallback", URL: "/"},
})
require.NoError(t, err)
assert.True(t, handled)
info := result.(WindowInfo)
assert.Equal(t, "test-fallback", info.Name)
}
func TestTaskOpenWindow_Bad(t *testing.T) {
@ -49,8 +64,8 @@ func TestTaskOpenWindow_Bad(t *testing.T) {
func TestQueryWindowList_Good(t *testing.T) {
_, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("a")}})
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("b")}})
_, _, _ = c.PERFORM(TaskOpenWindow{Window: Window{Name: "a"}})
_, _, _ = c.PERFORM(TaskOpenWindow{Window: Window{Name: "b"}})
result, handled, err := c.QUERY(QueryWindowList{})
require.NoError(t, err)
@ -61,7 +76,7 @@ func TestQueryWindowList_Good(t *testing.T) {
func TestQueryWindowByName_Good(t *testing.T) {
_, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
_, _, _ = c.PERFORM(TaskOpenWindow{Window: Window{Name: "test"}})
result, handled, err := c.QUERY(QueryWindowByName{Name: "test"})
require.NoError(t, err)
@ -78,13 +93,37 @@ func TestQueryWindowByName_Bad(t *testing.T) {
assert.Nil(t, result)
}
func TestQuerySavedWindowStates_Good(t *testing.T) {
_, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Window: Window{Name: "test"}})
_, _, _ = c.PERFORM(TaskSetPosition{Name: "test", X: 120, Y: 240})
result, handled, err := c.QUERY(QuerySavedWindowStates{})
require.NoError(t, err)
assert.True(t, handled)
states := result.(map[string]WindowState)
require.Contains(t, states, "test")
assert.Equal(t, 120, states["test"].X)
assert.Equal(t, 240, states["test"].Y)
}
func TestTaskCloseWindow_Good(t *testing.T) {
_, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
_, _, _ = c.PERFORM(TaskOpenWindow{Window: Window{Name: "test"}})
var closedCount int
c.RegisterAction(func(_ *core.Core, msg core.Message) error {
if _, ok := msg.(ActionWindowClosed); ok {
closedCount++
}
return nil
})
_, handled, err := c.PERFORM(TaskCloseWindow{Name: "test"})
require.NoError(t, err)
assert.True(t, handled)
assert.Equal(t, 1, closedCount)
// Verify window is removed
result, _, _ := c.QUERY(QueryWindowByName{Name: "test"})
@ -98,9 +137,23 @@ func TestTaskCloseWindow_Bad(t *testing.T) {
assert.Error(t, err)
}
func TestTaskResetWindowState_Good(t *testing.T) {
_, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Window: Window{Name: "test"}})
_, _, _ = c.PERFORM(TaskSetPosition{Name: "test", X: 80, Y: 90})
_, handled, err := c.PERFORM(TaskResetWindowState{})
require.NoError(t, err)
assert.True(t, handled)
result, _, err := c.QUERY(QuerySavedWindowStates{})
require.NoError(t, err)
assert.Empty(t, result.(map[string]WindowState))
}
func TestTaskSetPosition_Good(t *testing.T) {
_, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
_, _, _ = c.PERFORM(TaskOpenWindow{Window: Window{Name: "test"}})
_, handled, err := c.PERFORM(TaskSetPosition{Name: "test", X: 100, Y: 200})
require.NoError(t, err)
@ -114,9 +167,9 @@ func TestTaskSetPosition_Good(t *testing.T) {
func TestTaskSetSize_Good(t *testing.T) {
_, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
_, _, _ = c.PERFORM(TaskOpenWindow{Window: Window{Name: "test"}})
_, handled, err := c.PERFORM(TaskSetSize{Name: "test", W: 800, H: 600})
_, handled, err := c.PERFORM(TaskSetSize{Name: "test", Width: 800, Height: 600})
require.NoError(t, err)
assert.True(t, handled)
@ -126,11 +179,47 @@ func TestTaskSetSize_Good(t *testing.T) {
assert.Equal(t, 600, info.Height)
}
func TestTaskMaximise_Good(t *testing.T) {
_, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
func TestTaskSetBounds_Good(t *testing.T) {
svc, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Window: Window{Name: "test"}})
svc.Manager().State().SetState("test", WindowState{
Screen: "primary",
URL: "/app",
})
_, handled, err := c.PERFORM(TaskMaximise{Name: "test"})
_, handled, err := c.PERFORM(TaskSetBounds{
Name: "test",
X: 100,
Y: 200,
Width: 800,
Height: 600,
})
require.NoError(t, err)
assert.True(t, handled)
result, _, _ := c.QUERY(QueryWindowByName{Name: "test"})
info := result.(*WindowInfo)
assert.Equal(t, 100, info.X)
assert.Equal(t, 200, info.Y)
assert.Equal(t, 800, info.Width)
assert.Equal(t, 600, info.Height)
states, _, _ := c.QUERY(QuerySavedWindowStates{})
saved := states.(map[string]WindowState)
require.Contains(t, saved, "test")
assert.Equal(t, 100, saved["test"].X)
assert.Equal(t, 200, saved["test"].Y)
assert.Equal(t, 800, saved["test"].Width)
assert.Equal(t, 600, saved["test"].Height)
assert.Equal(t, "primary", saved["test"].Screen)
assert.Equal(t, "/app", saved["test"].URL)
}
func TestTaskMaximize_Good(t *testing.T) {
_, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Window: Window{Name: "test"}})
_, handled, err := c.PERFORM(TaskMaximize{Name: "test"})
require.NoError(t, err)
assert.True(t, handled)
@ -144,7 +233,7 @@ func TestFileDrop_Good(t *testing.T) {
// Open a window
result, _, _ := c.PERFORM(TaskOpenWindow{
Opts: []WindowOption{WithName("drop-test")},
Window: Window{Name: "drop-test"},
})
info := result.(WindowInfo)
assert.Equal(t, "drop-test", info.Name)
@ -174,3 +263,399 @@ func TestFileDrop_Good(t *testing.T) {
assert.Equal(t, "upload-zone", dropped.TargetID)
mu.Unlock()
}
func TestWindowResizeEvent_UsesCanonicalPayload_Good(t *testing.T) {
svc, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Window: Window{Name: "resize-test"}})
var (
resized ActionWindowResized
seen bool
)
c.RegisterAction(func(_ *core.Core, msg core.Message) error {
if a, ok := msg.(ActionWindowResized); ok {
resized = a
seen = true
}
return nil
})
pw, ok := svc.Manager().Get("resize-test")
require.True(t, ok)
mw := pw.(*mockWindow)
mw.emit(WindowEvent{
Type: "resize",
Name: "resize-test",
Data: map[string]any{"width": 111, "height": 222},
})
assert.True(t, seen)
assert.Equal(t, "resize-test", resized.Name)
assert.Equal(t, 111, resized.Width)
assert.Equal(t, 222, resized.Height)
resized = ActionWindowResized{}
seen = false
mw.emit(WindowEvent{
Type: "resize",
Name: "resize-test",
Data: map[string]any{"w": 333, "h": 444},
})
assert.True(t, seen)
assert.Equal(t, "resize-test", resized.Name)
assert.Equal(t, 0, resized.Width)
assert.Equal(t, 0, resized.Height)
}
// --- TaskMinimize ---
func TestTaskMinimize_Good(t *testing.T) {
svc, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Window: Window{Name: "test"}})
_, handled, err := c.PERFORM(TaskMinimize{Name: "test"})
require.NoError(t, err)
assert.True(t, handled)
pw, ok := svc.Manager().Get("test")
require.True(t, ok)
mw := pw.(*mockWindow)
assert.True(t, mw.minimised)
result, _, err := c.QUERY(QueryWindowByName{Name: "test"})
require.NoError(t, err)
info := result.(*WindowInfo)
assert.True(t, info.Minimized)
}
func TestTaskMinimize_Bad(t *testing.T) {
_, c := newTestWindowService(t)
_, handled, err := c.PERFORM(TaskMinimize{Name: "nonexistent"})
assert.True(t, handled)
assert.Error(t, err)
}
// --- TaskFocus ---
func TestTaskFocus_Good(t *testing.T) {
svc, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Window: Window{Name: "test"}})
_, handled, err := c.PERFORM(TaskFocus{Name: "test"})
require.NoError(t, err)
assert.True(t, handled)
pw, ok := svc.Manager().Get("test")
require.True(t, ok)
mw := pw.(*mockWindow)
assert.True(t, mw.focused)
}
func TestTaskFocus_Bad(t *testing.T) {
_, c := newTestWindowService(t)
_, handled, err := c.PERFORM(TaskFocus{Name: "nonexistent"})
assert.True(t, handled)
assert.Error(t, err)
}
// --- TaskRestore ---
func TestTaskRestore_Good(t *testing.T) {
svc, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Window: Window{Name: "test"}})
// First maximize, then restore
_, _, _ = c.PERFORM(TaskMaximize{Name: "test"})
_, handled, err := c.PERFORM(TaskRestore{Name: "test"})
require.NoError(t, err)
assert.True(t, handled)
pw, ok := svc.Manager().Get("test")
require.True(t, ok)
mw := pw.(*mockWindow)
assert.False(t, mw.maximised)
// Verify state was updated
state, ok := svc.Manager().State().GetState("test")
assert.True(t, ok)
assert.False(t, state.Maximized)
}
func TestTaskRestore_Bad(t *testing.T) {
_, c := newTestWindowService(t)
_, handled, err := c.PERFORM(TaskRestore{Name: "nonexistent"})
assert.True(t, handled)
assert.Error(t, err)
}
// --- TaskSetTitle ---
func TestTaskSetTitle_Good(t *testing.T) {
svc, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Window: Window{Name: "test"}})
_, handled, err := c.PERFORM(TaskSetTitle{Name: "test", Title: "New Title"})
require.NoError(t, err)
assert.True(t, handled)
pw, ok := svc.Manager().Get("test")
require.True(t, ok)
assert.Equal(t, "New Title", pw.Title())
}
func TestTaskSetTitle_Bad(t *testing.T) {
_, c := newTestWindowService(t)
_, handled, err := c.PERFORM(TaskSetTitle{Name: "nonexistent", Title: "Nope"})
assert.True(t, handled)
assert.Error(t, err)
}
// --- TaskSetAlwaysOnTop ---
func TestTaskSetAlwaysOnTop_Good(t *testing.T) {
svc, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Window: Window{Name: "test"}})
_, handled, err := c.PERFORM(TaskSetAlwaysOnTop{Name: "test", AlwaysOnTop: true})
require.NoError(t, err)
assert.True(t, handled)
pw, ok := svc.Manager().Get("test")
require.True(t, ok)
mw := pw.(*mockWindow)
assert.True(t, mw.alwaysOnTop)
}
func TestTaskSetAlwaysOnTop_Bad(t *testing.T) {
_, c := newTestWindowService(t)
_, handled, err := c.PERFORM(TaskSetAlwaysOnTop{Name: "nonexistent", AlwaysOnTop: true})
assert.True(t, handled)
assert.Error(t, err)
}
// --- TaskSetBackgroundColour ---
func TestTaskSetBackgroundColour_Good(t *testing.T) {
svc, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Window: Window{Name: "test"}})
_, handled, err := c.PERFORM(TaskSetBackgroundColour{
Name: "test", Red: 10, Green: 20, Blue: 30, Alpha: 40,
})
require.NoError(t, err)
assert.True(t, handled)
pw, ok := svc.Manager().Get("test")
require.True(t, ok)
mw := pw.(*mockWindow)
assert.Equal(t, [4]uint8{10, 20, 30, 40}, mw.backgroundColour)
}
func TestTaskSetBackgroundColour_Bad(t *testing.T) {
_, c := newTestWindowService(t)
_, handled, err := c.PERFORM(TaskSetBackgroundColour{Name: "nonexistent", Red: 1, Green: 2, Blue: 3, Alpha: 4})
assert.True(t, handled)
assert.Error(t, err)
}
// --- TaskSetVisibility ---
func TestTaskSetVisibility_Good(t *testing.T) {
svc, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Window: Window{Name: "test"}})
_, handled, err := c.PERFORM(TaskSetVisibility{Name: "test", Visible: true})
require.NoError(t, err)
assert.True(t, handled)
pw, ok := svc.Manager().Get("test")
require.True(t, ok)
mw := pw.(*mockWindow)
assert.True(t, mw.visible)
result, _, err := c.QUERY(QueryWindowByName{Name: "test"})
require.NoError(t, err)
info := result.(*WindowInfo)
assert.True(t, info.Visible)
// Now hide it
_, handled, err = c.PERFORM(TaskSetVisibility{Name: "test", Visible: false})
require.NoError(t, err)
assert.True(t, handled)
assert.False(t, mw.visible)
result, _, err = c.QUERY(QueryWindowByName{Name: "test"})
require.NoError(t, err)
info = result.(*WindowInfo)
assert.False(t, info.Visible)
}
func TestTaskSetVisibility_Bad(t *testing.T) {
_, c := newTestWindowService(t)
_, handled, err := c.PERFORM(TaskSetVisibility{Name: "nonexistent", Visible: true})
assert.True(t, handled)
assert.Error(t, err)
}
// --- TaskFullscreen ---
func TestTaskFullscreen_Good(t *testing.T) {
svc, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Window: Window{Name: "test"}})
// Enter fullscreen
_, handled, err := c.PERFORM(TaskFullscreen{Name: "test", Fullscreen: true})
require.NoError(t, err)
assert.True(t, handled)
pw, ok := svc.Manager().Get("test")
require.True(t, ok)
mw := pw.(*mockWindow)
assert.True(t, mw.fullscreened)
// Exit fullscreen
_, handled, err = c.PERFORM(TaskFullscreen{Name: "test", Fullscreen: false})
require.NoError(t, err)
assert.True(t, handled)
assert.False(t, mw.fullscreened)
}
func TestTaskFullscreen_Bad(t *testing.T) {
_, c := newTestWindowService(t)
_, handled, err := c.PERFORM(TaskFullscreen{Name: "nonexistent", Fullscreen: true})
assert.True(t, handled)
assert.Error(t, err)
}
// --- TaskSaveLayout ---
func TestTaskSaveLayout_Good(t *testing.T) {
svc, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Window: Window{Name: "editor", Width: 960, Height: 1080, X: 0, Y: 0}})
_, _, _ = c.PERFORM(TaskOpenWindow{Window: Window{Name: "terminal", Width: 960, Height: 1080, X: 960, Y: 0}})
_, handled, err := c.PERFORM(TaskSaveLayout{Name: "coding"})
require.NoError(t, err)
assert.True(t, handled)
// Verify layout was saved with correct window states
layout, ok := svc.Manager().Layout().GetLayout("coding")
assert.True(t, ok)
assert.Equal(t, "coding", layout.Name)
assert.Len(t, layout.Windows, 2)
editorState, ok := layout.Windows["editor"]
assert.True(t, ok)
assert.Equal(t, 0, editorState.X)
assert.Equal(t, 960, editorState.Width)
termState, ok := layout.Windows["terminal"]
assert.True(t, ok)
assert.Equal(t, 960, termState.X)
assert.Equal(t, 960, termState.Width)
}
func TestTaskSaveLayout_Bad(t *testing.T) {
_, c := newTestWindowService(t)
// Saving an empty layout with empty name returns an error from LayoutManager
_, handled, err := c.PERFORM(TaskSaveLayout{Name: ""})
assert.True(t, handled)
assert.Error(t, err)
}
// --- TaskRestoreLayout ---
func TestTaskRestoreLayout_Good(t *testing.T) {
svc, c := newTestWindowService(t)
// Open windows
_, _, _ = c.PERFORM(TaskOpenWindow{Window: Window{Name: "editor", Width: 800, Height: 600, X: 0, Y: 0}})
_, _, _ = c.PERFORM(TaskOpenWindow{Window: Window{Name: "terminal", Width: 800, Height: 600, X: 0, Y: 0}})
// Save a layout with specific positions
_, _, _ = c.PERFORM(TaskSaveLayout{Name: "coding"})
// Move the windows to different positions
_, _, _ = c.PERFORM(TaskSetPosition{Name: "editor", X: 500, Y: 500})
_, _, _ = c.PERFORM(TaskSetPosition{Name: "terminal", X: 600, Y: 600})
// Restore the layout
_, handled, err := c.PERFORM(TaskRestoreLayout{Name: "coding"})
require.NoError(t, err)
assert.True(t, handled)
// Verify windows were moved back to saved positions
pw, ok := svc.Manager().Get("editor")
require.True(t, ok)
x, y := pw.Position()
assert.Equal(t, 0, x)
assert.Equal(t, 0, y)
pw2, ok := svc.Manager().Get("terminal")
require.True(t, ok)
x2, y2 := pw2.Position()
assert.Equal(t, 0, x2)
assert.Equal(t, 0, y2)
editorState, ok := svc.Manager().State().GetState("editor")
require.True(t, ok)
assert.Equal(t, 0, editorState.X)
assert.Equal(t, 0, editorState.Y)
terminalState, ok := svc.Manager().State().GetState("terminal")
require.True(t, ok)
assert.Equal(t, 0, terminalState.X)
assert.Equal(t, 0, terminalState.Y)
}
func TestTaskRestoreLayout_Bad(t *testing.T) {
_, c := newTestWindowService(t)
_, handled, err := c.PERFORM(TaskRestoreLayout{Name: "nonexistent"})
assert.True(t, handled)
assert.Error(t, err)
}
// --- TaskStackWindows ---
func TestTaskStackWindows_Good(t *testing.T) {
svc, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Window: Window{Name: "s1", Width: 800, Height: 600}})
_, _, _ = c.PERFORM(TaskOpenWindow{Window: Window{Name: "s2", Width: 800, Height: 600}})
_, handled, err := c.PERFORM(TaskStackWindows{Windows: []string{"s1", "s2"}, OffsetX: 25, OffsetY: 35})
require.NoError(t, err)
assert.True(t, handled)
pw, ok := svc.Manager().Get("s2")
require.True(t, ok)
x, y := pw.Position()
assert.Equal(t, 25, x)
assert.Equal(t, 35, y)
}
// --- TaskApplyWorkflow ---
func TestTaskApplyWorkflow_Good(t *testing.T) {
svc, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Window: Window{Name: "editor", Width: 800, Height: 600}})
_, _, _ = c.PERFORM(TaskOpenWindow{Window: Window{Name: "terminal", Width: 800, Height: 600}})
_, handled, err := c.PERFORM(TaskApplyWorkflow{Workflow: "side-by-side"})
require.NoError(t, err)
assert.True(t, handled)
editor, ok := svc.Manager().Get("editor")
require.True(t, ok)
x, y := editor.Position()
assert.Equal(t, 0, x)
assert.Equal(t, 0, y)
terminal, ok := svc.Manager().Get("terminal")
require.True(t, ok)
x, y = terminal.Position()
assert.Equal(t, 960, x)
assert.Equal(t, 0, y)
}

View file

@ -5,12 +5,15 @@ import (
"encoding/json"
"os"
"path/filepath"
"sort"
"sync"
"time"
coreio "forge.lthn.ai/core/go-io"
)
// WindowState holds the persisted position/size of a window.
// JSON tags match existing window_state.json format for backward compat.
// JSON tags match the existing window_state.json format.
type WindowState struct {
X int `json:"x,omitempty"`
Y int `json:"y,omitempty"`
@ -25,6 +28,7 @@ type WindowState struct {
// StateManager persists window positions to ~/.config/Core/window_state.json.
type StateManager struct {
configDir string
statePath string
states map[string]WindowState
mu sync.RWMutex
saveTimer *time.Timer
@ -55,24 +59,46 @@ func NewStateManagerWithDir(configDir string) *StateManager {
}
func (sm *StateManager) filePath() string {
if sm.statePath != "" {
return sm.statePath
}
return filepath.Join(sm.configDir, "window_state.json")
}
func (sm *StateManager) load() {
if sm.configDir == "" {
func (sm *StateManager) dataDir() string {
if sm.statePath != "" {
return filepath.Dir(sm.statePath)
}
return sm.configDir
}
func (sm *StateManager) SetPath(path string) {
if path == "" {
return
}
data, err := os.ReadFile(sm.filePath())
sm.mu.Lock()
sm.stopSaveTimerLocked()
sm.statePath = path
sm.states = make(map[string]WindowState)
sm.mu.Unlock()
sm.load()
}
func (sm *StateManager) load() {
if sm.configDir == "" && sm.statePath == "" {
return
}
content, err := coreio.Local.Read(sm.filePath())
if err != nil {
return
}
sm.mu.Lock()
defer sm.mu.Unlock()
_ = json.Unmarshal(data, &sm.states)
_ = json.Unmarshal([]byte(content), &sm.states)
}
func (sm *StateManager) save() {
if sm.configDir == "" {
if sm.configDir == "" && sm.statePath == "" {
return
}
sm.mu.RLock()
@ -81,15 +107,34 @@ func (sm *StateManager) save() {
if err != nil {
return
}
_ = os.MkdirAll(sm.configDir, 0o755)
_ = os.WriteFile(sm.filePath(), data, 0o644)
if dir := sm.dataDir(); dir != "" {
_ = coreio.Local.EnsureDir(dir)
}
_ = coreio.Local.Write(sm.filePath(), string(data))
}
func (sm *StateManager) scheduleSave() {
sm.mu.Lock()
sm.stopSaveTimerLocked()
sm.saveTimer = time.AfterFunc(500*time.Millisecond, sm.save)
sm.mu.Unlock()
}
func (sm *StateManager) stopSaveTimerLocked() {
if sm.saveTimer != nil {
sm.saveTimer.Stop()
sm.saveTimer = nil
}
sm.saveTimer = time.AfterFunc(500*time.Millisecond, sm.save)
}
func (sm *StateManager) updateState(name string, mutate func(*WindowState)) {
sm.mu.Lock()
state := sm.states[name]
mutate(&state)
state.UpdatedAt = time.Now().UnixMilli()
sm.states[name] = state
sm.mu.Unlock()
sm.scheduleSave()
}
// GetState returns the saved state for a window name.
@ -102,55 +147,58 @@ func (sm *StateManager) GetState(name string) (WindowState, bool) {
// SetState saves state for a window name (debounced disk write).
func (sm *StateManager) SetState(name string, state WindowState) {
state.UpdatedAt = time.Now().UnixMilli()
sm.mu.Lock()
sm.states[name] = state
sm.mu.Unlock()
sm.scheduleSave()
sm.updateState(name, func(current *WindowState) {
*current = state
})
}
// UpdatePosition updates only the position fields.
func (sm *StateManager) UpdatePosition(name string, x, y int) {
sm.mu.Lock()
s := sm.states[name]
s.X = x
s.Y = y
s.UpdatedAt = time.Now().UnixMilli()
sm.states[name] = s
sm.mu.Unlock()
sm.scheduleSave()
sm.updateState(name, func(state *WindowState) {
state.X = x
state.Y = y
})
}
// UpdateSize updates only the size fields.
func (sm *StateManager) UpdateSize(name string, width, height int) {
sm.mu.Lock()
s := sm.states[name]
s.Width = width
s.Height = height
s.UpdatedAt = time.Now().UnixMilli()
sm.states[name] = s
sm.mu.Unlock()
sm.scheduleSave()
sm.updateState(name, func(state *WindowState) {
state.Width = width
state.Height = height
})
}
// UpdateBounds updates position and size in one state write.
func (sm *StateManager) UpdateBounds(name string, x, y, width, height int) {
sm.updateState(name, func(state *WindowState) {
state.X = x
state.Y = y
state.Width = width
state.Height = height
})
}
// UpdateMaximized updates the maximized flag.
func (sm *StateManager) UpdateMaximized(name string, maximized bool) {
sm.mu.Lock()
s := sm.states[name]
s.Maximized = maximized
s.UpdatedAt = time.Now().UnixMilli()
sm.states[name] = s
sm.mu.Unlock()
sm.scheduleSave()
sm.updateState(name, func(state *WindowState) {
state.Maximized = maximized
})
}
// CaptureState snapshots the current state from a PlatformWindow.
func (sm *StateManager) CaptureState(pw PlatformWindow) {
if pw == nil {
return
}
x, y := pw.Position()
w, h := pw.Size()
sm.SetState(pw.Name(), WindowState{
X: x, Y: y, Width: w, Height: h,
Maximized: pw.IsMaximised(),
name := pw.Name()
sm.updateState(name, func(state *WindowState) {
state.X = x
state.Y = y
state.Width = w
state.Height = h
state.Maximized = pw.IsMaximised()
})
}
@ -178,6 +226,7 @@ func (sm *StateManager) ListStates() []string {
for name := range sm.states {
names = append(names, name)
}
sort.Strings(names)
return names
}
@ -191,8 +240,8 @@ func (sm *StateManager) Clear() {
// ForceSync writes state to disk immediately.
func (sm *StateManager) ForceSync() {
if sm.saveTimer != nil {
sm.saveTimer.Stop()
}
sm.mu.Lock()
sm.stopSaveTimerLocked()
sm.mu.Unlock()
sm.save()
}

View file

@ -1,7 +1,7 @@
// pkg/window/tiling.go
package window
import "fmt"
import coreerr "forge.lthn.ai/core/go-log"
// TileMode defines how windows are arranged.
type TileMode int
@ -44,13 +44,23 @@ const (
SnapCenter
)
var snapPositionNames = map[SnapPosition]string{
SnapLeft: "left", SnapRight: "right",
SnapTop: "top", SnapBottom: "bottom",
SnapTopLeft: "top-left", SnapTopRight: "top-right",
SnapBottomLeft: "bottom-left", SnapBottomRight: "bottom-right",
SnapCenter: "center",
}
func (p SnapPosition) String() string { return snapPositionNames[p] }
// WorkflowLayout is a predefined arrangement for common tasks.
type WorkflowLayout int
const (
WorkflowCoding WorkflowLayout = iota // 70/30 split
WorkflowDebugging // 60/40 split
WorkflowPresenting // maximised
WorkflowPresenting // maximized
WorkflowSideBySide // 50/50 split
)
@ -61,18 +71,36 @@ var workflowNames = map[WorkflowLayout]string{
func (w WorkflowLayout) String() string { return workflowNames[w] }
func layoutOrigin(origin []int) (int, int) {
if len(origin) == 0 {
return 0, 0
}
if len(origin) == 1 {
return origin[0], 0
}
return origin[0], origin[1]
}
func (m *Manager) captureState(pw PlatformWindow) {
if m.state == nil || pw == nil {
return
}
m.state.CaptureState(pw)
}
// TileWindows arranges the named windows in the given mode across the screen area.
func (m *Manager) TileWindows(mode TileMode, names []string, screenW, screenH int) error {
func (m *Manager) TileWindows(mode TileMode, names []string, screenW, screenH int, origin ...int) error {
originX, originY := layoutOrigin(origin)
windows := make([]PlatformWindow, 0, len(names))
for _, name := range names {
pw, ok := m.Get(name)
if !ok {
return fmt.Errorf("window %q not found", name)
return coreerr.E("window.Manager.TileWindows", "window not found: "+name, nil)
}
windows = append(windows, pw)
}
if len(windows) == 0 {
return fmt.Errorf("no windows to tile")
return coreerr.E("window.Manager.TileWindows", "no windows to tile", nil)
}
halfW, halfH := screenW/2, screenH/2
@ -81,8 +109,8 @@ func (m *Manager) TileWindows(mode TileMode, names []string, screenW, screenH in
case TileModeLeftRight:
w := screenW / len(windows)
for i, pw := range windows {
pw.SetPosition(i*w, 0)
pw.SetSize(w, screenH)
pw.SetBounds(originX+i*w, originY, w, screenH)
m.captureState(pw)
}
case TileModeGrid:
cols := 2
@ -95,110 +123,107 @@ func (m *Manager) TileWindows(mode TileMode, names []string, screenW, screenH in
col := i % cols
rows := (len(windows) + cols - 1) / cols
cellH := screenH / rows
pw.SetPosition(col*cellW, row*cellH)
pw.SetSize(cellW, cellH)
pw.SetBounds(originX+col*cellW, originY+row*cellH, cellW, cellH)
m.captureState(pw)
}
case TileModeLeftHalf:
for _, pw := range windows {
pw.SetPosition(0, 0)
pw.SetSize(halfW, screenH)
pw.SetBounds(originX, originY, halfW, screenH)
m.captureState(pw)
}
case TileModeRightHalf:
for _, pw := range windows {
pw.SetPosition(halfW, 0)
pw.SetSize(halfW, screenH)
pw.SetBounds(originX+halfW, originY, halfW, screenH)
m.captureState(pw)
}
case TileModeTopHalf:
for _, pw := range windows {
pw.SetPosition(0, 0)
pw.SetSize(screenW, halfH)
pw.SetBounds(originX, originY, screenW, halfH)
m.captureState(pw)
}
case TileModeBottomHalf:
for _, pw := range windows {
pw.SetPosition(0, halfH)
pw.SetSize(screenW, halfH)
pw.SetBounds(originX, originY+halfH, screenW, halfH)
m.captureState(pw)
}
case TileModeTopLeft:
for _, pw := range windows {
pw.SetPosition(0, 0)
pw.SetSize(halfW, halfH)
pw.SetBounds(originX, originY, halfW, halfH)
m.captureState(pw)
}
case TileModeTopRight:
for _, pw := range windows {
pw.SetPosition(halfW, 0)
pw.SetSize(halfW, halfH)
pw.SetBounds(originX+halfW, originY, halfW, halfH)
m.captureState(pw)
}
case TileModeBottomLeft:
for _, pw := range windows {
pw.SetPosition(0, halfH)
pw.SetSize(halfW, halfH)
pw.SetBounds(originX, originY+halfH, halfW, halfH)
m.captureState(pw)
}
case TileModeBottomRight:
for _, pw := range windows {
pw.SetPosition(halfW, halfH)
pw.SetSize(halfW, halfH)
pw.SetBounds(originX+halfW, originY+halfH, halfW, halfH)
m.captureState(pw)
}
}
return nil
}
// SnapWindow snaps a window to a screen edge/corner/centre.
func (m *Manager) SnapWindow(name string, pos SnapPosition, screenW, screenH int) error {
// SnapWindow snaps a window to a screen edge, corner, or center.
func (m *Manager) SnapWindow(name string, pos SnapPosition, screenW, screenH int, origin ...int) error {
originX, originY := layoutOrigin(origin)
pw, ok := m.Get(name)
if !ok {
return fmt.Errorf("window %q not found", name)
return coreerr.E("window.Manager.SnapWindow", "window not found: "+name, nil)
}
halfW, halfH := screenW/2, screenH/2
switch pos {
case SnapLeft:
pw.SetPosition(0, 0)
pw.SetSize(halfW, screenH)
pw.SetBounds(originX, originY, halfW, screenH)
case SnapRight:
pw.SetPosition(halfW, 0)
pw.SetSize(halfW, screenH)
pw.SetBounds(originX+halfW, originY, halfW, screenH)
case SnapTop:
pw.SetPosition(0, 0)
pw.SetSize(screenW, halfH)
pw.SetBounds(originX, originY, screenW, halfH)
case SnapBottom:
pw.SetPosition(0, halfH)
pw.SetSize(screenW, halfH)
pw.SetBounds(originX, originY+halfH, screenW, halfH)
case SnapTopLeft:
pw.SetPosition(0, 0)
pw.SetSize(halfW, halfH)
pw.SetBounds(originX, originY, halfW, halfH)
case SnapTopRight:
pw.SetPosition(halfW, 0)
pw.SetSize(halfW, halfH)
pw.SetBounds(originX+halfW, originY, halfW, halfH)
case SnapBottomLeft:
pw.SetPosition(0, halfH)
pw.SetSize(halfW, halfH)
pw.SetBounds(originX, originY+halfH, halfW, halfH)
case SnapBottomRight:
pw.SetPosition(halfW, halfH)
pw.SetSize(halfW, halfH)
pw.SetBounds(originX+halfW, originY+halfH, halfW, halfH)
case SnapCenter:
cw, ch := pw.Size()
pw.SetPosition((screenW-cw)/2, (screenH-ch)/2)
pw.SetBounds(originX+(screenW-cw)/2, originY+(screenH-ch)/2, cw, ch)
}
m.captureState(pw)
return nil
}
// StackWindows cascades windows with an offset.
func (m *Manager) StackWindows(names []string, offsetX, offsetY int) error {
func (m *Manager) StackWindows(names []string, offsetX, offsetY int, origin ...int) error {
originX, originY := layoutOrigin(origin)
for i, name := range names {
pw, ok := m.Get(name)
if !ok {
return fmt.Errorf("window %q not found", name)
return coreerr.E("window.Manager.StackWindows", "window not found: "+name, nil)
}
pw.SetPosition(i*offsetX, i*offsetY)
pw.SetPosition(originX+i*offsetX, originY+i*offsetY)
m.captureState(pw)
}
return nil
}
// ApplyWorkflow arranges windows in a predefined workflow layout.
func (m *Manager) ApplyWorkflow(workflow WorkflowLayout, names []string, screenW, screenH int) error {
func (m *Manager) ApplyWorkflow(workflow WorkflowLayout, names []string, screenW, screenH int, origin ...int) error {
originX, originY := layoutOrigin(origin)
if len(names) == 0 {
return fmt.Errorf("no windows for workflow")
return coreerr.E("window.Manager.ApplyWorkflow", "no windows for workflow", nil)
}
switch workflow {
@ -206,36 +231,36 @@ func (m *Manager) ApplyWorkflow(workflow WorkflowLayout, names []string, screenW
// 70/30 split — main editor + terminal
mainW := screenW * 70 / 100
if pw, ok := m.Get(names[0]); ok {
pw.SetPosition(0, 0)
pw.SetSize(mainW, screenH)
pw.SetBounds(originX, originY, mainW, screenH)
m.captureState(pw)
}
if len(names) > 1 {
if pw, ok := m.Get(names[1]); ok {
pw.SetPosition(mainW, 0)
pw.SetSize(screenW-mainW, screenH)
pw.SetBounds(originX+mainW, originY, screenW-mainW, screenH)
m.captureState(pw)
}
}
case WorkflowDebugging:
// 60/40 split
mainW := screenW * 60 / 100
if pw, ok := m.Get(names[0]); ok {
pw.SetPosition(0, 0)
pw.SetSize(mainW, screenH)
pw.SetBounds(originX, originY, mainW, screenH)
m.captureState(pw)
}
if len(names) > 1 {
if pw, ok := m.Get(names[1]); ok {
pw.SetPosition(mainW, 0)
pw.SetSize(screenW-mainW, screenH)
pw.SetBounds(originX+mainW, originY, screenW-mainW, screenH)
m.captureState(pw)
}
}
case WorkflowPresenting:
// Maximise first window
// Maximize first window
if pw, ok := m.Get(names[0]); ok {
pw.SetPosition(0, 0)
pw.SetSize(screenW, screenH)
pw.SetBounds(originX, originY, screenW, screenH)
m.captureState(pw)
}
case WorkflowSideBySide:
return m.TileWindows(TileModeLeftRight, names, screenW, screenH)
return m.TileWindows(TileModeLeftRight, names, screenW, screenH, originX, originY)
}
return nil
}

View file

@ -11,44 +11,44 @@ type WailsPlatform struct {
app *application.App
}
// NewWailsPlatform creates a Wails-backed Platform.
// NewWailsPlatform returns a Platform backed by a Wails app.
func NewWailsPlatform(app *application.App) *WailsPlatform {
return &WailsPlatform{app: app}
}
func (wp *WailsPlatform) CreateWindow(opts PlatformWindowOptions) PlatformWindow {
wOpts := application.WebviewWindowOptions{
Name: opts.Name,
Title: opts.Title,
URL: opts.URL,
Width: opts.Width,
Height: opts.Height,
X: opts.X,
Y: opts.Y,
MinWidth: opts.MinWidth,
MinHeight: opts.MinHeight,
MaxWidth: opts.MaxWidth,
MaxHeight: opts.MaxHeight,
Frameless: opts.Frameless,
Hidden: opts.Hidden,
AlwaysOnTop: opts.AlwaysOnTop,
DisableResize: opts.DisableResize,
EnableFileDrop: opts.EnableFileDrop,
BackgroundColour: application.NewRGBA(opts.BackgroundColour[0], opts.BackgroundColour[1], opts.BackgroundColour[2], opts.BackgroundColour[3]),
func (platform *WailsPlatform) CreateWindow(options PlatformWindowOptions) PlatformWindow {
windowOptions := application.WebviewWindowOptions{
Name: options.Name,
Title: options.Title,
URL: options.URL,
Width: options.Width,
Height: options.Height,
X: options.X,
Y: options.Y,
MinWidth: options.MinWidth,
MinHeight: options.MinHeight,
MaxWidth: options.MaxWidth,
MaxHeight: options.MaxHeight,
Frameless: options.Frameless,
Hidden: options.Hidden,
AlwaysOnTop: options.AlwaysOnTop,
DisableResize: options.DisableResize,
EnableFileDrop: options.EnableFileDrop,
BackgroundColour: application.NewRGBA(options.BackgroundColour[0], options.BackgroundColour[1], options.BackgroundColour[2], options.BackgroundColour[3]),
}
w := wp.app.Window.NewWithOptions(wOpts)
return &wailsWindow{w: w, title: opts.Title}
windowHandle := platform.app.Window.NewWithOptions(windowOptions)
return &wailsWindow{w: windowHandle, title: options.Title}
}
func (wp *WailsPlatform) GetWindows() []PlatformWindow {
all := wp.app.Window.GetAll()
out := make([]PlatformWindow, 0, len(all))
for _, w := range all {
if wv, ok := w.(*application.WebviewWindow); ok {
out = append(out, &wailsWindow{w: wv})
func (platform *WailsPlatform) GetWindows() []PlatformWindow {
allWindows := platform.app.Window.GetAll()
platformWindows := make([]PlatformWindow, 0, len(allWindows))
for _, window := range allWindows {
if windowHandle, ok := window.(*application.WebviewWindow); ok {
platformWindows = append(platformWindows, &wailsWindow{w: windowHandle})
}
}
return out
return platformWindows
}
// wailsWindow wraps *application.WebviewWindow to implement PlatformWindow.
@ -58,41 +58,52 @@ type wailsWindow struct {
title string
}
func (ww *wailsWindow) Name() string { return ww.w.Name() }
func (ww *wailsWindow) Title() string { return ww.title }
func (ww *wailsWindow) Position() (int, int) { return ww.w.Position() }
func (ww *wailsWindow) Size() (int, int) { return ww.w.Size() }
func (ww *wailsWindow) IsMaximised() bool { return ww.w.IsMaximised() }
func (ww *wailsWindow) IsFocused() bool { return ww.w.IsFocused() }
func (ww *wailsWindow) SetTitle(title string) { ww.title = title; ww.w.SetTitle(title) }
func (ww *wailsWindow) SetPosition(x, y int) { ww.w.SetPosition(x, y) }
func (ww *wailsWindow) SetSize(width, height int) { ww.w.SetSize(width, height) }
func (ww *wailsWindow) SetBackgroundColour(r, g, b, a uint8) {
ww.w.SetBackgroundColour(application.NewRGBA(r, g, b, a))
func (windowHandle *wailsWindow) Name() string { return windowHandle.w.Name() }
func (windowHandle *wailsWindow) Title() string { return windowHandle.title }
func (windowHandle *wailsWindow) Position() (int, int) { return windowHandle.w.Position() }
func (windowHandle *wailsWindow) Size() (int, int) { return windowHandle.w.Size() }
func (windowHandle *wailsWindow) IsMaximised() bool { return windowHandle.w.IsMaximised() }
func (windowHandle *wailsWindow) IsMinimised() bool { return windowHandle.w.IsMinimised() }
func (windowHandle *wailsWindow) IsVisible() bool { return windowHandle.w.IsVisible() }
func (windowHandle *wailsWindow) IsFocused() bool { return windowHandle.w.IsFocused() }
func (windowHandle *wailsWindow) SetTitle(title string) {
windowHandle.title = title
windowHandle.w.SetTitle(title)
}
func (ww *wailsWindow) SetVisibility(visible bool) {
func (windowHandle *wailsWindow) SetBounds(x, y, width, height int) {
windowHandle.w.SetPosition(x, y)
windowHandle.w.SetSize(width, height)
}
func (windowHandle *wailsWindow) SetPosition(x, y int) { windowHandle.w.SetPosition(x, y) }
func (windowHandle *wailsWindow) SetSize(width, height int) { windowHandle.w.SetSize(width, height) }
func (windowHandle *wailsWindow) SetBackgroundColour(r, g, b, a uint8) {
windowHandle.w.SetBackgroundColour(application.NewRGBA(r, g, b, a))
}
func (windowHandle *wailsWindow) SetVisibility(visible bool) {
if visible {
ww.w.Show()
windowHandle.w.Show()
} else {
ww.w.Hide()
windowHandle.w.Hide()
}
}
func (ww *wailsWindow) SetAlwaysOnTop(alwaysOnTop bool) { ww.w.SetAlwaysOnTop(alwaysOnTop) }
func (ww *wailsWindow) Maximise() { ww.w.Maximise() }
func (ww *wailsWindow) Restore() { ww.w.Restore() }
func (ww *wailsWindow) Minimise() { ww.w.Minimise() }
func (ww *wailsWindow) Focus() { ww.w.Focus() }
func (ww *wailsWindow) Close() { ww.w.Close() }
func (ww *wailsWindow) Show() { ww.w.Show() }
func (ww *wailsWindow) Hide() { ww.w.Hide() }
func (ww *wailsWindow) Fullscreen() { ww.w.Fullscreen() }
func (ww *wailsWindow) UnFullscreen() { ww.w.UnFullscreen() }
func (windowHandle *wailsWindow) SetAlwaysOnTop(alwaysOnTop bool) {
windowHandle.w.SetAlwaysOnTop(alwaysOnTop)
}
func (windowHandle *wailsWindow) Maximise() { windowHandle.w.Maximise() }
func (windowHandle *wailsWindow) Restore() { windowHandle.w.Restore() }
func (windowHandle *wailsWindow) Minimise() { windowHandle.w.Minimise() }
func (windowHandle *wailsWindow) Focus() { windowHandle.w.Focus() }
func (windowHandle *wailsWindow) Close() { windowHandle.w.Close() }
func (windowHandle *wailsWindow) Show() { windowHandle.w.Show() }
func (windowHandle *wailsWindow) Hide() { windowHandle.w.Hide() }
func (windowHandle *wailsWindow) Fullscreen() { windowHandle.w.Fullscreen() }
func (windowHandle *wailsWindow) UnFullscreen() { windowHandle.w.UnFullscreen() }
func (ww *wailsWindow) OnWindowEvent(handler func(event WindowEvent)) {
name := ww.w.Name()
func (windowHandle *wailsWindow) OnWindowEvent(handler func(event WindowEvent)) {
name := windowHandle.w.Name()
// Map common Wails window events to our WindowEvent type.
eventMap := map[events.WindowEventType]string{
windowEventMap := map[events.WindowEventType]string{
events.Common.WindowFocus: "focus",
events.Common.WindowLostFocus: "blur",
events.Common.WindowDidMove: "move",
@ -100,22 +111,22 @@ func (ww *wailsWindow) OnWindowEvent(handler func(event WindowEvent)) {
events.Common.WindowClosing: "close",
}
for eventType, eventName := range eventMap {
typeName := eventName // capture for closure
ww.w.OnWindowEvent(eventType, func(event *application.WindowEvent) {
for eventType, eventName := range windowEventMap {
mappedEventName := eventName // capture for closure
windowHandle.w.OnWindowEvent(eventType, func(event *application.WindowEvent) {
data := make(map[string]any)
switch typeName {
switch mappedEventName {
case "move":
x, y := ww.w.Position()
x, y := windowHandle.w.Position()
data["x"] = x
data["y"] = y
case "resize":
w, h := ww.w.Size()
data["width"] = w
data["height"] = h
width, height := windowHandle.w.Size()
data["width"] = width
data["height"] = height
}
handler(WindowEvent{
Type: typeName,
Type: mappedEventName,
Name: name,
Data: data,
})
@ -123,8 +134,8 @@ func (ww *wailsWindow) OnWindowEvent(handler func(event WindowEvent)) {
}
}
func (ww *wailsWindow) OnFileDrop(handler func(paths []string, targetID string)) {
ww.w.OnWindowEvent(events.Common.WindowFilesDropped, func(event *application.WindowEvent) {
func (windowHandle *wailsWindow) OnFileDrop(handler func(paths []string, targetID string)) {
windowHandle.w.OnWindowEvent(events.Common.WindowFilesDropped, func(event *application.WindowEvent) {
files := event.Context().DroppedFiles()
details := event.Context().DropTargetDetails()
targetID := ""
@ -140,4 +151,3 @@ var _ PlatformWindow = (*wailsWindow)(nil)
// Ensure WailsPlatform satisfies Platform at compile time.
var _ Platform = (*WailsPlatform)(nil)

View file

@ -2,25 +2,29 @@
package window
import (
"fmt"
"sort"
"sync"
)
// Window is CoreGUI's own window descriptor — NOT a Wails type alias.
// Window is CoreGUI's own window descriptor, not a Wails type alias.
type Window struct {
Name string
Title string
URL string
Width, Height int
X, Y int
MinWidth, MinHeight int
MaxWidth, MaxHeight int
Frameless bool
Hidden bool
AlwaysOnTop bool
BackgroundColour [4]uint8
DisableResize bool
EnableFileDrop bool
Name string `json:"name,omitempty"`
Title string `json:"title,omitempty"`
URL string `json:"url,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
X int `json:"x,omitempty"`
Y int `json:"y,omitempty"`
MinWidth int `json:"minWidth,omitempty"`
MinHeight int `json:"minHeight,omitempty"`
MaxWidth int `json:"maxWidth,omitempty"`
MaxHeight int `json:"maxHeight,omitempty"`
Frameless bool `json:"frameless,omitempty"`
Hidden bool `json:"hidden,omitempty"`
AlwaysOnTop bool `json:"alwaysOnTop,omitempty"`
BackgroundColour [4]uint8 `json:"backgroundColour,omitempty"`
DisableResize bool `json:"disableResize,omitempty"`
EnableFileDrop bool `json:"enableFileDrop,omitempty"`
}
// ToPlatformOptions converts a Window to PlatformWindowOptions for the backend.
@ -38,11 +42,13 @@ func (w *Window) ToPlatformOptions() PlatformWindowOptions {
// Manager manages window lifecycle through a Platform backend.
type Manager struct {
platform Platform
state *StateManager
layout *LayoutManager
windows map[string]PlatformWindow
mu sync.RWMutex
platform Platform
state *StateManager
layout *LayoutManager
windows map[string]PlatformWindow
defaultWidth int
defaultHeight int
mu sync.RWMutex
}
// NewManager creates a window Manager with the given platform backend.
@ -66,17 +72,23 @@ func NewManagerWithDir(platform Platform, configDir string) *Manager {
}
}
// Open creates a window using functional options, applies saved state, and tracks it.
func (m *Manager) Open(opts ...WindowOption) (PlatformWindow, error) {
w, err := ApplyOptions(opts...)
if err != nil {
return nil, fmt.Errorf("window.Manager.Open: %w", err)
func (m *Manager) SetDefaultWidth(width int) {
if width > 0 {
m.defaultWidth = width
}
return m.Create(w)
}
// Create creates a window from a Window descriptor.
func (m *Manager) Create(w *Window) (PlatformWindow, error) {
func (m *Manager) SetDefaultHeight(height int) {
if height > 0 {
m.defaultHeight = height
}
}
// Create opens a window from a declarative spec.
// Example: m.Create(Window{Name: "editor", Title: "Editor", URL: "/"})
// Saved position, size, and maximized state are restored when available.
func (m *Manager) Create(spec Window) (PlatformWindow, error) {
w := spec
if w.Name == "" {
w.Name = "main"
}
@ -84,19 +96,34 @@ func (m *Manager) Create(w *Window) (PlatformWindow, error) {
w.Title = "Core"
}
if w.Width == 0 {
w.Width = 1280
if m.defaultWidth > 0 {
w.Width = m.defaultWidth
} else {
w.Width = 1280
}
}
if w.Height == 0 {
w.Height = 800
if m.defaultHeight > 0 {
w.Height = m.defaultHeight
} else {
w.Height = 800
}
}
if w.URL == "" {
w.URL = "/"
}
// Apply saved state if available
m.state.ApplyState(w)
// Apply saved state if available.
if m.state != nil {
m.state.ApplyState(&w)
}
pw := m.platform.CreateWindow(w.ToPlatformOptions())
if m.state != nil {
if state, ok := m.state.GetState(w.Name); ok && state.Maximized {
pw.Maximise()
}
}
m.mu.Lock()
m.windows[w.Name] = pw
@ -121,6 +148,7 @@ func (m *Manager) List() []string {
for name := range m.windows {
names = append(names, name)
}
sort.Strings(names)
return names
}

View file

@ -14,71 +14,25 @@ func TestWindowDefaults(t *testing.T) {
assert.Equal(t, 0, w.Width)
}
func TestWindowOption_Name_Good(t *testing.T) {
w := &Window{}
err := WithName("main")(w)
require.NoError(t, err)
func TestWindowSpec_Good(t *testing.T) {
w := Window{
Name: "main",
Title: "My App",
URL: "/dashboard",
Width: 1280,
Height: 720,
X: 100,
Y: 200,
}
assert.Equal(t, "main", w.Name)
}
func TestWindowOption_Title_Good(t *testing.T) {
w := &Window{}
err := WithTitle("My App")(w)
require.NoError(t, err)
assert.Equal(t, "My App", w.Title)
}
func TestWindowOption_URL_Good(t *testing.T) {
w := &Window{}
err := WithURL("/dashboard")(w)
require.NoError(t, err)
assert.Equal(t, "/dashboard", w.URL)
}
func TestWindowOption_Size_Good(t *testing.T) {
w := &Window{}
err := WithSize(1280, 720)(w)
require.NoError(t, err)
assert.Equal(t, 1280, w.Width)
assert.Equal(t, 720, w.Height)
}
func TestWindowOption_Position_Good(t *testing.T) {
w := &Window{}
err := WithPosition(100, 200)(w)
require.NoError(t, err)
assert.Equal(t, 100, w.X)
assert.Equal(t, 200, w.Y)
}
func TestApplyOptions_Good(t *testing.T) {
w, err := ApplyOptions(
WithName("test"),
WithTitle("Test Window"),
WithURL("/test"),
WithSize(800, 600),
)
require.NoError(t, err)
assert.Equal(t, "test", w.Name)
assert.Equal(t, "Test Window", w.Title)
assert.Equal(t, "/test", w.URL)
assert.Equal(t, 800, w.Width)
assert.Equal(t, 600, w.Height)
}
func TestApplyOptions_Bad(t *testing.T) {
_, err := ApplyOptions(func(w *Window) error {
return assert.AnError
})
assert.Error(t, err)
}
func TestApplyOptions_Empty_Good(t *testing.T) {
w, err := ApplyOptions()
require.NoError(t, err)
assert.NotNil(t, w)
}
// newTestManager creates a Manager with a mock platform and clean state for testing.
func newTestManager() (*Manager, *mockPlatform) {
p := newMockPlatform()
@ -91,18 +45,18 @@ func newTestManager() (*Manager, *mockPlatform) {
return m, p
}
func TestManager_Open_Good(t *testing.T) {
func TestManager_Create_Good(t *testing.T) {
m, p := newTestManager()
pw, err := m.Open(WithName("test"), WithTitle("Test"), WithURL("/test"), WithSize(800, 600))
pw, err := m.Create(Window{Name: "test", Title: "Test", URL: "/test", Width: 800, Height: 600})
require.NoError(t, err)
assert.NotNil(t, pw)
assert.Equal(t, "test", pw.Name())
assert.Len(t, p.windows, 1)
}
func TestManager_Open_Defaults_Good(t *testing.T) {
func TestManager_Create_Defaults_Good(t *testing.T) {
m, _ := newTestManager()
pw, err := m.Open()
pw, err := m.Create(Window{})
require.NoError(t, err)
assert.Equal(t, "main", pw.Name())
w, h := pw.Size()
@ -110,15 +64,31 @@ func TestManager_Open_Defaults_Good(t *testing.T) {
assert.Equal(t, 800, h)
}
func TestManager_Open_Bad(t *testing.T) {
func TestManager_Create_CustomDefaults_Good(t *testing.T) {
m, _ := newTestManager()
_, err := m.Open(func(w *Window) error { return assert.AnError })
assert.Error(t, err)
m.SetDefaultWidth(1440)
m.SetDefaultHeight(900)
pw, err := m.Create(Window{})
require.NoError(t, err)
w, h := pw.Size()
assert.Equal(t, 1440, w)
assert.Equal(t, 900, h)
}
func TestManager_Create_RestoresMaximizedState_Good(t *testing.T) {
m, _ := newTestManager()
m.state.states["restored"] = WindowState{Maximized: true}
pw, err := m.Create(Window{Name: "restored"})
require.NoError(t, err)
assert.True(t, pw.IsMaximised())
}
func TestManager_Get_Good(t *testing.T) {
m, _ := newTestManager()
_, _ = m.Open(WithName("findme"))
_, _ = m.Create(Window{Name: "findme"})
pw, ok := m.Get("findme")
assert.True(t, ok)
assert.Equal(t, "findme", pw.Name())
@ -132,8 +102,8 @@ func TestManager_Get_Bad(t *testing.T) {
func TestManager_List_Good(t *testing.T) {
m, _ := newTestManager()
_, _ = m.Open(WithName("a"))
_, _ = m.Open(WithName("b"))
_, _ = m.Create(Window{Name: "a"})
_, _ = m.Create(Window{Name: "b"})
names := m.List()
assert.Len(t, names, 2)
assert.Contains(t, names, "a")
@ -142,137 +112,12 @@ func TestManager_List_Good(t *testing.T) {
func TestManager_Remove_Good(t *testing.T) {
m, _ := newTestManager()
_, _ = m.Open(WithName("temp"))
_, _ = m.Create(Window{Name: "temp"})
m.Remove("temp")
_, ok := m.Get("temp")
assert.False(t, ok)
}
// --- StateManager Tests ---
// newTestStateManager creates a clean StateManager with a temp dir for testing.
func newTestStateManager(t *testing.T) *StateManager {
return &StateManager{
configDir: t.TempDir(),
states: make(map[string]WindowState),
}
}
func TestStateManager_SetGet_Good(t *testing.T) {
sm := newTestStateManager(t)
state := WindowState{X: 100, Y: 200, Width: 800, Height: 600, Maximized: false}
sm.SetState("main", state)
got, ok := sm.GetState("main")
assert.True(t, ok)
assert.Equal(t, 100, got.X)
assert.Equal(t, 800, got.Width)
}
func TestStateManager_SetGet_Bad(t *testing.T) {
sm := newTestStateManager(t)
_, ok := sm.GetState("nonexistent")
assert.False(t, ok)
}
func TestStateManager_CaptureState_Good(t *testing.T) {
sm := newTestStateManager(t)
w := &mockWindow{name: "cap", x: 50, y: 60, width: 1024, height: 768, maximised: true}
sm.CaptureState(w)
got, ok := sm.GetState("cap")
assert.True(t, ok)
assert.Equal(t, 50, got.X)
assert.Equal(t, 1024, got.Width)
assert.True(t, got.Maximized)
}
func TestStateManager_ApplyState_Good(t *testing.T) {
sm := newTestStateManager(t)
sm.SetState("win", WindowState{X: 10, Y: 20, Width: 640, Height: 480})
w := &Window{Name: "win", Width: 1280, Height: 800}
sm.ApplyState(w)
assert.Equal(t, 10, w.X)
assert.Equal(t, 20, w.Y)
assert.Equal(t, 640, w.Width)
assert.Equal(t, 480, w.Height)
}
func TestStateManager_ListStates_Good(t *testing.T) {
sm := newTestStateManager(t)
sm.SetState("a", WindowState{Width: 100})
sm.SetState("b", WindowState{Width: 200})
names := sm.ListStates()
assert.Len(t, names, 2)
}
func TestStateManager_Clear_Good(t *testing.T) {
sm := newTestStateManager(t)
sm.SetState("a", WindowState{Width: 100})
sm.Clear()
names := sm.ListStates()
assert.Empty(t, names)
}
func TestStateManager_Persistence_Good(t *testing.T) {
dir := t.TempDir()
sm1 := &StateManager{configDir: dir, states: make(map[string]WindowState)}
sm1.SetState("persist", WindowState{X: 42, Y: 84, Width: 500, Height: 300})
sm1.ForceSync()
sm2 := &StateManager{configDir: dir, states: make(map[string]WindowState)}
sm2.load()
got, ok := sm2.GetState("persist")
assert.True(t, ok)
assert.Equal(t, 42, got.X)
assert.Equal(t, 500, got.Width)
}
// --- LayoutManager Tests ---
// newTestLayoutManager creates a clean LayoutManager with a temp dir for testing.
func newTestLayoutManager(t *testing.T) *LayoutManager {
return &LayoutManager{
configDir: t.TempDir(),
layouts: make(map[string]Layout),
}
}
func TestLayoutManager_SaveGet_Good(t *testing.T) {
lm := newTestLayoutManager(t)
states := map[string]WindowState{
"editor": {X: 0, Y: 0, Width: 960, Height: 1080},
"terminal": {X: 960, Y: 0, Width: 960, Height: 1080},
}
err := lm.SaveLayout("coding", states)
require.NoError(t, err)
layout, ok := lm.GetLayout("coding")
assert.True(t, ok)
assert.Equal(t, "coding", layout.Name)
assert.Len(t, layout.Windows, 2)
}
func TestLayoutManager_GetLayout_Bad(t *testing.T) {
lm := newTestLayoutManager(t)
_, ok := lm.GetLayout("nonexistent")
assert.False(t, ok)
}
func TestLayoutManager_ListLayouts_Good(t *testing.T) {
lm := newTestLayoutManager(t)
_ = lm.SaveLayout("a", map[string]WindowState{})
_ = lm.SaveLayout("b", map[string]WindowState{})
layouts := lm.ListLayouts()
assert.Len(t, layouts, 2)
}
func TestLayoutManager_DeleteLayout_Good(t *testing.T) {
lm := newTestLayoutManager(t)
_ = lm.SaveLayout("temp", map[string]WindowState{})
lm.DeleteLayout("temp")
_, ok := lm.GetLayout("temp")
assert.False(t, ok)
}
// --- Tiling Tests ---
func TestTileMode_String_Good(t *testing.T) {
@ -282,8 +127,8 @@ func TestTileMode_String_Good(t *testing.T) {
func TestManager_TileWindows_Good(t *testing.T) {
m, _ := newTestManager()
_, _ = m.Open(WithName("a"), WithSize(800, 600))
_, _ = m.Open(WithName("b"), WithSize(800, 600))
_, _ = m.Create(Window{Name: "a", Width: 800, Height: 600})
_, _ = m.Create(Window{Name: "b", Width: 800, Height: 600})
err := m.TileWindows(TileModeLeftRight, []string{"a", "b"}, 1920, 1080)
require.NoError(t, err)
a, _ := m.Get("a")
@ -302,7 +147,7 @@ func TestManager_TileWindows_Bad(t *testing.T) {
func TestManager_SnapWindow_Good(t *testing.T) {
m, _ := newTestManager()
_, _ = m.Open(WithName("snap"), WithSize(800, 600))
_, _ = m.Create(Window{Name: "snap", Width: 800, Height: 600})
err := m.SnapWindow("snap", SnapLeft, 1920, 1080)
require.NoError(t, err)
w, _ := m.Get("snap")
@ -314,8 +159,8 @@ func TestManager_SnapWindow_Good(t *testing.T) {
func TestManager_StackWindows_Good(t *testing.T) {
m, _ := newTestManager()
_, _ = m.Open(WithName("s1"), WithSize(800, 600))
_, _ = m.Open(WithName("s2"), WithSize(800, 600))
_, _ = m.Create(Window{Name: "s1", Width: 800, Height: 600})
_, _ = m.Create(Window{Name: "s2", Width: 800, Height: 600})
err := m.StackWindows([]string{"s1", "s2"}, 30, 30)
require.NoError(t, err)
s2, _ := m.Get("s2")
@ -328,3 +173,190 @@ func TestWorkflowLayout_Good(t *testing.T) {
assert.Equal(t, "coding", WorkflowCoding.String())
assert.Equal(t, "debugging", WorkflowDebugging.String())
}
// --- Comprehensive Tiling Tests ---
func TestTileWindows_AllModes_Good(t *testing.T) {
const screenW, screenH = 1920, 1080
halfW, halfH := screenW/2, screenH/2
tests := []struct {
name string
mode TileMode
wantX int
wantY int
wantWidth int
wantHeight int
}{
{"LeftHalf", TileModeLeftHalf, 0, 0, halfW, screenH},
{"RightHalf", TileModeRightHalf, halfW, 0, halfW, screenH},
{"TopHalf", TileModeTopHalf, 0, 0, screenW, halfH},
{"BottomHalf", TileModeBottomHalf, 0, halfH, screenW, halfH},
{"TopLeft", TileModeTopLeft, 0, 0, halfW, halfH},
{"TopRight", TileModeTopRight, halfW, 0, halfW, halfH},
{"BottomLeft", TileModeBottomLeft, 0, halfH, halfW, halfH},
{"BottomRight", TileModeBottomRight, halfW, halfH, halfW, halfH},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
m, _ := newTestManager()
_, err := m.Create(Window{Name: "win", Width: 800, Height: 600})
require.NoError(t, err)
err = m.TileWindows(tc.mode, []string{"win"}, screenW, screenH)
require.NoError(t, err)
pw, ok := m.Get("win")
require.True(t, ok)
x, y := pw.Position()
w, h := pw.Size()
assert.Equal(t, tc.wantX, x, "x position")
assert.Equal(t, tc.wantY, y, "y position")
assert.Equal(t, tc.wantWidth, w, "width")
assert.Equal(t, tc.wantHeight, h, "height")
})
}
}
func TestSnapWindow_AllPositions_Good(t *testing.T) {
const screenW, screenH = 1920, 1080
halfW, halfH := screenW/2, screenH/2
tests := []struct {
name string
pos SnapPosition
initW int
initH int
wantX int
wantY int
wantWidth int
wantHeight int
}{
{"Right", SnapRight, 800, 600, halfW, 0, halfW, screenH},
{"Top", SnapTop, 800, 600, 0, 0, screenW, halfH},
{"Bottom", SnapBottom, 800, 600, 0, halfH, screenW, halfH},
{"TopLeft", SnapTopLeft, 800, 600, 0, 0, halfW, halfH},
{"TopRight", SnapTopRight, 800, 600, halfW, 0, halfW, halfH},
{"BottomLeft", SnapBottomLeft, 800, 600, 0, halfH, halfW, halfH},
{"BottomRight", SnapBottomRight, 800, 600, halfW, halfH, halfW, halfH},
{"Center", SnapCenter, 800, 600, (screenW - 800) / 2, (screenH - 600) / 2, 800, 600},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
m, _ := newTestManager()
_, err := m.Create(Window{Name: "snap", Width: tc.initW, Height: tc.initH})
require.NoError(t, err)
err = m.SnapWindow("snap", tc.pos, screenW, screenH)
require.NoError(t, err)
pw, ok := m.Get("snap")
require.True(t, ok)
x, y := pw.Position()
w, h := pw.Size()
assert.Equal(t, tc.wantX, x, "x position")
assert.Equal(t, tc.wantY, y, "y position")
assert.Equal(t, tc.wantWidth, w, "width")
assert.Equal(t, tc.wantHeight, h, "height")
})
}
}
func TestStackWindows_ThreeWindows_Good(t *testing.T) {
m, _ := newTestManager()
names := []string{"s1", "s2", "s3"}
for _, name := range names {
_, err := m.Create(Window{Name: name, Width: 800, Height: 600})
require.NoError(t, err)
}
err := m.StackWindows(names, 30, 30)
require.NoError(t, err)
for i, name := range names {
pw, ok := m.Get(name)
require.True(t, ok, "window %s should exist", name)
x, y := pw.Position()
assert.Equal(t, i*30, x, "window %s x position", name)
assert.Equal(t, i*30, y, "window %s y position", name)
}
}
func TestApplyWorkflow_AllLayouts_Good(t *testing.T) {
const screenW, screenH = 1920, 1080
tests := []struct {
name string
workflow WorkflowLayout
// Expected positions/sizes for the first two windows.
// For WorkflowSideBySide, TileWindows(LeftRight) divides equally.
win0X, win0Y, win0W, win0H int
win1X, win1Y, win1W, win1H int
}{
{
"Coding",
WorkflowCoding,
0, 0, 1344, screenH, // 70% of 1920 = 1344
1344, 0, screenW - 1344, screenH, // remaining 30%
},
{
"Debugging",
WorkflowDebugging,
0, 0, 1152, screenH, // 60% of 1920 = 1152
1152, 0, screenW - 1152, screenH, // remaining 40%
},
{
"Presenting",
WorkflowPresenting,
0, 0, screenW, screenH, // maximized
0, 0, 800, 600, // second window untouched
},
{
"SideBySide",
WorkflowSideBySide,
0, 0, 960, screenH, // left half (1920/2)
960, 0, 960, screenH, // right half
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
m, _ := newTestManager()
_, err := m.Create(Window{Name: "editor", Width: 800, Height: 600})
require.NoError(t, err)
_, err = m.Create(Window{Name: "terminal", Width: 800, Height: 600})
require.NoError(t, err)
err = m.ApplyWorkflow(tc.workflow, []string{"editor", "terminal"}, screenW, screenH)
require.NoError(t, err)
pw0, ok := m.Get("editor")
require.True(t, ok)
x0, y0 := pw0.Position()
w0, h0 := pw0.Size()
assert.Equal(t, tc.win0X, x0, "editor x")
assert.Equal(t, tc.win0Y, y0, "editor y")
assert.Equal(t, tc.win0W, w0, "editor width")
assert.Equal(t, tc.win0H, h0, "editor height")
pw1, ok := m.Get("terminal")
require.True(t, ok)
x1, y1 := pw1.Position()
w1, h1 := pw1.Size()
assert.Equal(t, tc.win1X, x1, "terminal x")
assert.Equal(t, tc.win1Y, y1, "terminal y")
assert.Equal(t, tc.win1W, w1, "terminal width")
assert.Equal(t, tc.win1H, h1, "terminal height")
})
}
}
func TestApplyWorkflow_Empty_Bad(t *testing.T) {
m, _ := newTestManager()
err := m.ApplyWorkflow(WorkflowCoding, []string{}, 1920, 1080)
assert.Error(t, err)
}

3
stubs/wails/go.mod Normal file
View file

@ -0,0 +1,3 @@
module github.com/wailsapp/wails/v3
go 1.26.0

View file

@ -0,0 +1,381 @@
package application
import (
"sync"
"github.com/wailsapp/wails/v3/pkg/events"
)
// Context mirrors the callback context type exposed by Wails.
type Context struct{}
// Logger is a minimal logger surface used by the GUI packages.
type Logger struct{}
func (l Logger) Info(message string, args ...any) {}
// RGBA stores a colour with alpha.
type RGBA struct {
Red, Green, Blue, Alpha uint8
}
// NewRGBA constructs an RGBA value.
func NewRGBA(red, green, blue, alpha uint8) RGBA {
return RGBA{Red: red, Green: green, Blue: blue, Alpha: alpha}
}
// MenuRole identifies a platform menu role.
type MenuRole int
const (
AppMenu MenuRole = iota
FileMenu
EditMenu
ViewMenu
WindowMenu
HelpMenu
)
// MenuItem is a minimal menu item implementation.
type MenuItem struct {
Label string
Accelerator string
Tooltip string
Checked bool
Enabled bool
onClick func(*Context)
}
func (mi *MenuItem) SetAccelerator(accel string) { mi.Accelerator = accel }
func (mi *MenuItem) SetTooltip(text string) { mi.Tooltip = text }
func (mi *MenuItem) SetChecked(checked bool) { mi.Checked = checked }
func (mi *MenuItem) SetEnabled(enabled bool) { mi.Enabled = enabled }
func (mi *MenuItem) OnClick(fn func(*Context)) { mi.onClick = fn }
// Menu is a minimal menu tree used by the GUI wrappers.
type Menu struct {
Items []*MenuItem
}
func NewMenu() *Menu { return &Menu{} }
func (m *Menu) Add(label string) *MenuItem {
item := &MenuItem{Label: label, Enabled: true}
m.Items = append(m.Items, item)
return item
}
func (m *Menu) AddSeparator() {
m.Items = append(m.Items, &MenuItem{Label: "---"})
}
func (m *Menu) AddSubmenu(label string) *Menu {
submenu := &Menu{}
m.Items = append(m.Items, &MenuItem{Label: label})
return submenu
}
func (m *Menu) AddRole(role MenuRole) {
m.Items = append(m.Items, &MenuItem{Label: role.String(), Enabled: true})
}
func (role MenuRole) String() string {
switch role {
case AppMenu:
return "app"
case FileMenu:
return "file"
case EditMenu:
return "edit"
case ViewMenu:
return "view"
case WindowMenu:
return "window"
case HelpMenu:
return "help"
default:
return "unknown"
}
}
// MenuManager owns the application menu.
type MenuManager struct {
applicationMenu *Menu
}
func (m *MenuManager) SetApplicationMenu(menu *Menu) { m.applicationMenu = menu }
// SystemTray represents a tray instance.
type SystemTray struct {
icon []byte
templateIcon []byte
tooltip string
label string
menu *Menu
attachedWindow *WebviewWindow
}
func (t *SystemTray) SetIcon(data []byte) { t.icon = append([]byte(nil), data...) }
func (t *SystemTray) SetTemplateIcon(data []byte) { t.templateIcon = append([]byte(nil), data...) }
func (t *SystemTray) SetTooltip(text string) { t.tooltip = text }
func (t *SystemTray) SetLabel(text string) { t.label = text }
func (t *SystemTray) SetMenu(menu *Menu) { t.menu = menu }
func (t *SystemTray) AttachWindow(w *WebviewWindow) {
t.attachedWindow = w
}
// SystemTrayManager creates tray instances.
type SystemTrayManager struct{}
func (m *SystemTrayManager) New() *SystemTray { return &SystemTray{} }
// WindowEventContext carries drag-and-drop details for a window event.
type WindowEventContext struct {
droppedFiles []string
dropDetails *DropTargetDetails
}
func (c *WindowEventContext) DroppedFiles() []string {
return append([]string(nil), c.droppedFiles...)
}
func (c *WindowEventContext) DropTargetDetails() *DropTargetDetails {
if c.dropDetails == nil {
return nil
}
details := *c.dropDetails
return &details
}
// DropTargetDetails mirrors the fields consumed by the GUI wrappers.
type DropTargetDetails struct {
ElementID string
}
// WindowEvent mirrors the event object passed to window callbacks.
type WindowEvent struct {
ctx *WindowEventContext
}
func (e *WindowEvent) Context() *WindowEventContext {
if e.ctx == nil {
e.ctx = &WindowEventContext{}
}
return e.ctx
}
// WebviewWindowOptions configures a window instance.
type WebviewWindowOptions struct {
Name string
Title string
URL string
Width, Height int
X, Y int
MinWidth, MinHeight int
MaxWidth, MaxHeight int
Frameless bool
Hidden bool
AlwaysOnTop bool
DisableResize bool
EnableFileDrop bool
BackgroundColour RGBA
}
// WebviewWindow is a lightweight, in-memory window implementation.
type WebviewWindow struct {
mu sync.RWMutex
opts WebviewWindowOptions
title string
x, y int
width, height int
maximised bool
minimised bool
focused bool
visible bool
alwaysOnTop bool
fullscreen bool
closed bool
eventHandlers map[events.WindowEventType][]func(*WindowEvent)
}
func newWebviewWindow(options WebviewWindowOptions) *WebviewWindow {
return &WebviewWindow{
opts: options,
title: options.Title,
x: options.X,
y: options.Y,
width: options.Width,
height: options.Height,
visible: !options.Hidden,
alwaysOnTop: options.AlwaysOnTop,
eventHandlers: make(map[events.WindowEventType][]func(*WindowEvent)),
}
}
func (w *WebviewWindow) Name() string { return w.opts.Name }
func (w *WebviewWindow) Title() string {
w.mu.RLock()
defer w.mu.RUnlock()
return w.title
}
func (w *WebviewWindow) Position() (int, int) {
w.mu.RLock()
defer w.mu.RUnlock()
return w.x, w.y
}
func (w *WebviewWindow) Size() (int, int) {
w.mu.RLock()
defer w.mu.RUnlock()
return w.width, w.height
}
func (w *WebviewWindow) IsMaximised() bool {
w.mu.RLock()
defer w.mu.RUnlock()
return w.maximised
}
func (w *WebviewWindow) IsMinimised() bool {
w.mu.RLock()
defer w.mu.RUnlock()
return w.minimised
}
func (w *WebviewWindow) IsVisible() bool {
w.mu.RLock()
defer w.mu.RUnlock()
return w.visible
}
func (w *WebviewWindow) IsFocused() bool {
w.mu.RLock()
defer w.mu.RUnlock()
return w.focused
}
func (w *WebviewWindow) SetTitle(title string) {
w.mu.Lock()
w.title = title
w.mu.Unlock()
}
func (w *WebviewWindow) SetPosition(x, y int) {
w.mu.Lock()
w.x = x
w.y = y
w.mu.Unlock()
}
func (w *WebviewWindow) SetSize(width, height int) {
w.mu.Lock()
w.width = width
w.height = height
w.mu.Unlock()
}
func (w *WebviewWindow) SetBackgroundColour(colour RGBA) {}
func (w *WebviewWindow) SetAlwaysOnTop(alwaysOnTop bool) {
w.mu.Lock()
w.alwaysOnTop = alwaysOnTop
w.mu.Unlock()
}
func (w *WebviewWindow) Maximise() {
w.mu.Lock()
w.maximised = true
w.minimised = false
w.mu.Unlock()
}
func (w *WebviewWindow) Restore() {
w.mu.Lock()
w.maximised = false
w.minimised = false
w.fullscreen = false
w.mu.Unlock()
}
func (w *WebviewWindow) Minimise() {
w.mu.Lock()
w.maximised = false
w.minimised = true
w.mu.Unlock()
}
func (w *WebviewWindow) Focus() {
w.mu.Lock()
w.focused = true
w.mu.Unlock()
}
func (w *WebviewWindow) Close() {
w.mu.Lock()
w.closed = true
w.mu.Unlock()
}
func (w *WebviewWindow) Show() {
w.mu.Lock()
w.visible = true
w.mu.Unlock()
}
func (w *WebviewWindow) Hide() {
w.mu.Lock()
w.visible = false
w.mu.Unlock()
}
func (w *WebviewWindow) Fullscreen() {
w.mu.Lock()
w.fullscreen = true
w.mu.Unlock()
}
func (w *WebviewWindow) UnFullscreen() {
w.mu.Lock()
w.fullscreen = false
w.mu.Unlock()
}
func (w *WebviewWindow) OnWindowEvent(eventType events.WindowEventType, callback func(event *WindowEvent)) func() {
w.mu.Lock()
w.eventHandlers[eventType] = append(w.eventHandlers[eventType], callback)
w.mu.Unlock()
return func() {}
}
// WindowManager manages in-memory windows.
type WindowManager struct {
mu sync.RWMutex
windows []*WebviewWindow
}
func (wm *WindowManager) NewWithOptions(options WebviewWindowOptions) *WebviewWindow {
window := newWebviewWindow(options)
wm.mu.Lock()
wm.windows = append(wm.windows, window)
wm.mu.Unlock()
return window
}
func (wm *WindowManager) GetAll() []any {
wm.mu.RLock()
defer wm.mu.RUnlock()
out := make([]any, 0, len(wm.windows))
for _, window := range wm.windows {
out = append(out, window)
}
return out
}
// App is the top-level application object used by the GUI packages.
type App struct {
Logger Logger
Window WindowManager
Menu MenuManager
SystemTray SystemTrayManager
}
func (a *App) Quit() {}
func (a *App) NewMenu() *Menu {
return NewMenu()
}

View file

@ -0,0 +1,30 @@
package events
// WindowEventType identifies a window event emitted by the application layer.
type WindowEventType int
const (
WindowFocus WindowEventType = iota
WindowLostFocus
WindowDidMove
WindowDidResize
WindowClosing
WindowFilesDropped
)
// Common matches the event namespace used by the real Wails package.
var Common = struct {
WindowFocus WindowEventType
WindowLostFocus WindowEventType
WindowDidMove WindowEventType
WindowDidResize WindowEventType
WindowClosing WindowEventType
WindowFilesDropped WindowEventType
}{
WindowFocus: WindowFocus,
WindowLostFocus: WindowLostFocus,
WindowDidMove: WindowDidMove,
WindowDidResize: WindowDidResize,
WindowClosing: WindowClosing,
WindowFilesDropped: WindowFilesDropped,
}