diff --git a/CLAUDE.md b/CLAUDE.md
index 594d4c0..ca734bc 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
diff --git a/docs/framework/display.md b/docs/framework/display.md
index 96f01b8..68b824e 100644
--- a/docs/framework/display.md
+++ b/docs/framework/display.md
@@ -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.
diff --git a/docs/framework/help.md b/docs/framework/help.md
index 8f2614f..3adf228 100644
--- a/docs/framework/help.md
+++ b/docs/framework/help.md
@@ -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.
diff --git a/docs/ref/wails-v3/go.mod b/docs/ref/wails-v3/go.mod
new file mode 100644
index 0000000..7dcb832
--- /dev/null
+++ b/docs/ref/wails-v3/go.mod
@@ -0,0 +1,3 @@
+module github.com/wailsapp/wails/v3
+
+go 1.26.0
diff --git a/docs/ref/wails-v3/src/application/assets/index.html b/docs/ref/wails-v3/src/application/assets/index.html
new file mode 100644
index 0000000..36bba67
--- /dev/null
+++ b/docs/ref/wails-v3/src/application/assets/index.html
@@ -0,0 +1,9 @@
+
+
+
+
+ Wails Assets Placeholder
+
+
+
+
diff --git a/go.mod b/go.mod
index 62cc623..77122c4 100644
--- a/go.mod
+++ b/go.mod
@@ -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
)
diff --git a/go.sum b/go.sum
index 9845e27..486e4fe 100644
--- a/go.sum
+++ b/go.sum
@@ -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=
diff --git a/pkg/browser/register.go b/pkg/browser/register.go
index ff081e7..204686a 100644
--- a/pkg/browser/register.go
+++ b/pkg/browser/register.go
@@ -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{
diff --git a/pkg/browser/service.go b/pkg/browser/service.go
index a3b8915..13000b9 100644
--- a/pkg/browser/service.go
+++ b/pkg/browser/service.go
@@ -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
}
diff --git a/pkg/contextmenu/messages.go b/pkg/contextmenu/messages.go
index cb62e17..c5f131f 100644
--- a/pkg/contextmenu/messages.go
+++ b/pkg/contextmenu/messages.go
@@ -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"`
diff --git a/pkg/contextmenu/register.go b/pkg/contextmenu/register.go
index afb0604..f0c3100 100644
--- a/pkg/contextmenu/register.go
+++ b/pkg/contextmenu/register.go
@@ -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
}
}
diff --git a/pkg/contextmenu/service.go b/pkg/contextmenu/service.go
index 973346d..f6d97e4 100644
--- a/pkg/contextmenu/service.go
+++ b/pkg/contextmenu/service.go
@@ -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
}
diff --git a/pkg/contextmenu/service_test.go b/pkg/contextmenu/service_test.go
index 93dd8d3..edab171 100644
--- a/pkg/contextmenu/service_test.go
+++ b/pkg/contextmenu/service_test.go
@@ -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) {
diff --git a/pkg/dialog/messages.go b/pkg/dialog/messages.go
index 131592e..c274f2c 100644
--- a/pkg/dialog/messages.go
+++ b/pkg/dialog/messages.go
@@ -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 }
diff --git a/pkg/dialog/platform.go b/pkg/dialog/platform.go
index 80b74d7..10585a4 100644
--- a/pkg/dialog/platform.go
+++ b/pkg/dialog/platform.go
@@ -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.
diff --git a/pkg/dialog/service.go b/pkg/dialog/service.go
index 231f3be..b9b23b5 100644
--- a/pkg/dialog/service.go
+++ b/pkg/dialog/service.go
@@ -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
diff --git a/pkg/dialog/service_test.go b/pkg/dialog/service_test.go
index 66fe760..de476da 100644
--- a/pkg/dialog/service_test.go
+++ b/pkg/dialog/service_test.go
@@ -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"},
},
diff --git a/pkg/display/FEATURES.md b/pkg/display/FEATURES.md
index f336a61..a19dab5 100644
--- a/pkg/display/FEATURES.md
+++ b/pkg/display/FEATURES.md
@@ -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
diff --git a/pkg/display/README.md b/pkg/display/README.md
index f8f692b..21aec58 100644
--- a/pkg/display/README.md
+++ b/pkg/display/README.md
@@ -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.
diff --git a/pkg/display/display.go b/pkg/display/display.go
index 9b6b77a..775da23 100644
--- a/pkg/display/display.go
+++ b/pkg/display/display.go
@@ -2,14 +2,14 @@ package display
import (
"context"
- "fmt"
+ "encoding/json"
"os"
"path/filepath"
"runtime"
"forge.lthn.ai/core/config"
+ coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/go/pkg/core"
- "encoding/json"
"forge.lthn.ai/core/gui/pkg/browser"
"forge.lthn.ai/core/gui/pkg/contextmenu"
@@ -30,9 +30,6 @@ import (
// Options holds configuration for the display service.
type Options struct{}
-// WindowInfo is an alias for window.WindowInfo (backward compatibility).
-type WindowInfo = window.WindowInfo
-
// Service manages windowing, dialogs, and other visual elements.
// It orchestrates sub-services (window, systray, menu) via IPC and bridges
// IPC actions to WebSocket events for TypeScript apps.
@@ -40,31 +37,28 @@ type Service struct {
*core.ServiceRuntime[Options]
wailsApp *application.App
app App
- config Options
configData map[string]map[string]any
- cfg *config.Config // config instance for file persistence
- events *WSEventManager
+ configFile *config.Config // config instance for file persistence
+ events *WebSocketEventManager
}
-// New is the constructor for the display service.
-func New() (*Service, error) {
+// Display services start with an empty config cache.
+// svc := display.NewService()
+func NewService() *Service {
return &Service{
configData: map[string]map[string]any{
"window": {},
"systray": {},
"menu": {},
},
- }, nil
+ }
}
-// Register creates a factory closure that captures the Wails app.
-// Pass nil for testing without a Wails runtime.
+// Build a Core factory without an option chain.
+// factory := display.Register(nil)
func Register(wailsApp *application.App) func(*core.Core) (any, error) {
return func(c *core.Core) (any, error) {
- s, err := New()
- if err != nil {
- return nil, err
- }
+ s := NewService()
s.ServiceRuntime = core.NewServiceRuntime[Options](c, Options{})
s.wailsApp = wailsApp
return s, nil
@@ -72,182 +66,136 @@ func Register(wailsApp *application.App) func(*core.Core) (any, error) {
}
// OnStartup loads config and registers IPC handlers synchronously.
-// CRITICAL: config handlers MUST be registered before returning —
-// sub-services depend on them during their own OnStartup.
+// CRITICAL: config handlers MUST be registered before returning.
+// Sub-services depend on them during their own OnStartup.
func (s *Service) OnStartup(ctx context.Context) error {
s.loadConfig()
- // Register config query/task handlers — available NOW for sub-services
+ // Register config query/task handlers. Available now for sub-services.
s.Core().RegisterQuery(s.handleConfigQuery)
s.Core().RegisterTask(s.handleConfigTask)
// Initialise Wails wrappers if app is available (nil in tests)
if s.wailsApp != nil {
s.app = newWailsApp(s.wailsApp)
- s.events = NewWSEventManager()
+ s.events = NewWebSocketEventManager()
}
return nil
}
-// HandleIPCEvents is auto-discovered and registered by core.WithService.
-// It bridges sub-service IPC actions to WebSocket events for TS apps.
+// core.WithService auto-registers this handler to bridge sub-service IPC
+// actions to WebSocket events for TS apps.
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
switch m := msg.(type) {
case core.ActionServiceStartup:
- // All services have completed OnStartup — safe to PERFORM on sub-services
+ // All services have completed OnStartup. Safe to PERFORM on sub-services.
s.buildMenu()
s.setupTray()
case window.ActionWindowOpened:
- if s.events != nil {
- s.events.Emit(Event{Type: EventWindowCreate, Window: m.Name,
- Data: map[string]any{"name": m.Name}})
- }
+ s.emit(Event{Type: EventWindowCreate, Window: m.Name,
+ Data: map[string]any{"name": m.Name}})
case window.ActionWindowClosed:
- if s.events != nil {
- s.events.Emit(Event{Type: EventWindowClose, Window: m.Name,
- Data: map[string]any{"name": m.Name}})
- }
+ s.emit(Event{Type: EventWindowClose, Window: m.Name,
+ Data: map[string]any{"name": m.Name}})
case window.ActionWindowMoved:
- if s.events != nil {
- s.events.Emit(Event{Type: EventWindowMove, Window: m.Name,
- Data: map[string]any{"x": m.X, "y": m.Y}})
- }
+ s.emit(Event{Type: EventWindowMove, Window: m.Name,
+ Data: map[string]any{"x": m.X, "y": m.Y}})
case window.ActionWindowResized:
- if s.events != nil {
- s.events.Emit(Event{Type: EventWindowResize, Window: m.Name,
- Data: map[string]any{"w": m.W, "h": m.H}})
- }
+ s.emit(Event{Type: EventWindowResize, Window: m.Name,
+ Data: map[string]any{"width": m.Width, "height": m.Height}})
case window.ActionWindowFocused:
- if s.events != nil {
- s.events.Emit(Event{Type: EventWindowFocus, Window: m.Name})
- }
+ s.emit(Event{Type: EventWindowFocus, Window: m.Name})
case window.ActionWindowBlurred:
- if s.events != nil {
- s.events.Emit(Event{Type: EventWindowBlur, Window: m.Name})
- }
+ s.emit(Event{Type: EventWindowBlur, Window: m.Name})
case systray.ActionTrayClicked:
- if s.events != nil {
- s.events.Emit(Event{Type: EventTrayClick})
- }
+ s.emit(Event{Type: EventTrayClick})
case systray.ActionTrayMenuItemClicked:
- if s.events != nil {
- s.events.Emit(Event{Type: EventTrayMenuItemClick,
- Data: map[string]any{"actionId": m.ActionID}})
- }
+ s.emit(Event{Type: EventTrayMenuItemClick,
+ Data: map[string]any{"actionId": m.ActionID}})
s.handleTrayAction(m.ActionID)
case environment.ActionThemeChanged:
- if s.events != nil {
- theme := "light"
- if m.IsDark {
- theme = "dark"
- }
- s.events.Emit(Event{Type: EventThemeChange,
- Data: map[string]any{"isDark": m.IsDark, "theme": theme}})
+ theme := "light"
+ if m.IsDark {
+ theme = "dark"
}
+ s.emit(Event{Type: EventThemeChange,
+ Data: map[string]any{"isDark": m.IsDark, "theme": theme}})
case notification.ActionNotificationClicked:
- if s.events != nil {
- s.events.Emit(Event{Type: EventNotificationClick,
- Data: map[string]any{"id": m.ID}})
- }
+ s.emit(Event{Type: EventNotificationClick,
+ Data: map[string]any{"id": m.ID}})
case screen.ActionScreensChanged:
- if s.events != nil {
- s.events.Emit(Event{Type: EventScreenChange,
- Data: map[string]any{"screens": m.Screens}})
- }
+ s.emit(Event{Type: EventScreenChange,
+ Data: map[string]any{"screens": m.Screens}})
case keybinding.ActionTriggered:
- if s.events != nil {
- s.events.Emit(Event{Type: EventKeybindingTriggered,
- Data: map[string]any{"accelerator": m.Accelerator}})
- }
+ s.emit(Event{Type: EventKeybindingTriggered,
+ Data: map[string]any{"accelerator": m.Accelerator}})
case window.ActionFilesDropped:
- if s.events != nil {
- s.events.Emit(Event{Type: EventWindowFileDrop, Window: m.Name,
- Data: map[string]any{"paths": m.Paths, "targetId": m.TargetID}})
- }
+ s.emit(Event{Type: EventWindowFileDrop, Window: m.Name,
+ Data: map[string]any{"paths": m.Paths, "targetId": m.TargetID}})
case dock.ActionVisibilityChanged:
- if s.events != nil {
- s.events.Emit(Event{Type: EventDockVisibility,
- Data: map[string]any{"visible": m.Visible}})
- }
+ s.emit(Event{Type: EventDockVisibility,
+ Data: map[string]any{"visible": m.Visible}})
case lifecycle.ActionApplicationStarted:
- if s.events != nil {
- s.events.Emit(Event{Type: EventAppStarted})
- }
+ s.emit(Event{Type: EventAppStarted})
case lifecycle.ActionOpenedWithFile:
- if s.events != nil {
- s.events.Emit(Event{Type: EventAppOpenedWithFile,
- Data: map[string]any{"path": m.Path}})
- }
+ s.emit(Event{Type: EventAppOpenedWithFile,
+ Data: map[string]any{"path": m.Path}})
case lifecycle.ActionWillTerminate:
- if s.events != nil {
- s.events.Emit(Event{Type: EventAppWillTerminate})
- }
+ s.emit(Event{Type: EventAppWillTerminate})
case lifecycle.ActionDidBecomeActive:
- if s.events != nil {
- s.events.Emit(Event{Type: EventAppActive})
- }
+ s.emit(Event{Type: EventAppActive})
case lifecycle.ActionDidResignActive:
- if s.events != nil {
- s.events.Emit(Event{Type: EventAppInactive})
- }
+ s.emit(Event{Type: EventAppInactive})
case lifecycle.ActionPowerStatusChanged:
- if s.events != nil {
- s.events.Emit(Event{Type: EventSystemPowerChange})
- }
+ s.emit(Event{Type: EventSystemPowerChange})
case lifecycle.ActionSystemSuspend:
- if s.events != nil {
- s.events.Emit(Event{Type: EventSystemSuspend})
- }
+ s.emit(Event{Type: EventSystemSuspend})
case lifecycle.ActionSystemResume:
- if s.events != nil {
- s.events.Emit(Event{Type: EventSystemResume})
- }
+ s.emit(Event{Type: EventSystemResume})
case contextmenu.ActionItemClicked:
- if s.events != nil {
- s.events.Emit(Event{Type: EventContextMenuClick,
- Data: map[string]any{
- "menuName": m.MenuName,
- "actionId": m.ActionID,
- "data": m.Data,
- }})
- }
+ s.emit(Event{Type: EventContextMenuClick,
+ Data: map[string]any{
+ "menuName": m.MenuName,
+ "actionId": m.ActionID,
+ "data": m.Data,
+ }})
case webview.ActionConsoleMessage:
- if s.events != nil {
- s.events.Emit(Event{Type: EventWebviewConsole, Window: m.Window,
- Data: map[string]any{"message": m.Message}})
- }
+ s.emit(Event{Type: EventWebviewConsole, Window: m.Window,
+ Data: map[string]any{"message": m.Message}})
case webview.ActionException:
- if s.events != nil {
- s.events.Emit(Event{Type: EventWebviewException, Window: m.Window,
- Data: map[string]any{"exception": m.Exception}})
- }
+ s.emit(Event{Type: EventWebviewException, Window: m.Window,
+ Data: map[string]any{"exception": m.Exception}})
case ActionIDECommand:
- if s.events != nil {
- s.events.Emit(Event{Type: EventIDECommand,
- Data: map[string]any{"command": m.Command}})
- }
+ s.emit(Event{Type: EventIDECommand,
+ Data: map[string]any{"command": m.Command}})
}
return nil
}
-// WSMessage represents a command received from a WebSocket client.
-type WSMessage struct {
+func (s *Service) emit(event Event) {
+ if s.events != nil {
+ s.events.Emit(event)
+ }
+}
+
+// WebSocketMessage represents a command received from a WebSocket client.
+type WebSocketMessage struct {
Action string `json:"action"`
Data map[string]any `json:"data,omitempty"`
}
-// wsRequire extracts a string field from WS data and returns an error if it is empty.
-func wsRequire(data map[string]any, key string) (string, error) {
+// requireWebSocketField extracts a string field from WebSocket data and returns an error if it is empty.
+func requireWebSocketField(data map[string]any, key string) (string, error) {
v, _ := data[key].(string)
if v == "" {
- return "", fmt.Errorf("ws: missing required field %q", key)
+ return "", coreerr.E("display.requireWebSocketField", "missing required field \""+key+"\"", nil)
}
return v, nil
}
-// handleWSMessage bridges WebSocket commands to IPC calls.
-func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) {
+// handleWebSocketMessage bridges WebSocket commands to IPC calls.
+func (s *Service) handleWebSocketMessage(msg WebSocketMessage) (any, bool, error) {
var result any
var handled bool
var err error
@@ -300,51 +248,51 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) {
case "contextmenu:list":
result, handled, err = s.Core().QUERY(contextmenu.QueryList{})
case "webview:eval":
- w, e := wsRequire(msg.Data, "window")
+ w, e := requireWebSocketField(msg.Data, "window")
if e != nil {
return nil, false, e
}
script, _ := msg.Data["script"].(string)
result, handled, err = s.Core().PERFORM(webview.TaskEvaluate{Window: w, Script: script})
case "webview:click":
- w, e := wsRequire(msg.Data, "window")
+ w, e := requireWebSocketField(msg.Data, "window")
if e != nil {
return nil, false, e
}
- sel, e := wsRequire(msg.Data, "selector")
+ sel, e := requireWebSocketField(msg.Data, "selector")
if e != nil {
return nil, false, e
}
result, handled, err = s.Core().PERFORM(webview.TaskClick{Window: w, Selector: sel})
case "webview:type":
- w, e := wsRequire(msg.Data, "window")
+ w, e := requireWebSocketField(msg.Data, "window")
if e != nil {
return nil, false, e
}
- sel, e := wsRequire(msg.Data, "selector")
+ sel, e := requireWebSocketField(msg.Data, "selector")
if e != nil {
return nil, false, e
}
text, _ := msg.Data["text"].(string)
result, handled, err = s.Core().PERFORM(webview.TaskType{Window: w, Selector: sel, Text: text})
case "webview:navigate":
- w, e := wsRequire(msg.Data, "window")
+ w, e := requireWebSocketField(msg.Data, "window")
if e != nil {
return nil, false, e
}
- url, e := wsRequire(msg.Data, "url")
+ url, e := requireWebSocketField(msg.Data, "url")
if e != nil {
return nil, false, e
}
result, handled, err = s.Core().PERFORM(webview.TaskNavigate{Window: w, URL: url})
case "webview:screenshot":
- w, e := wsRequire(msg.Data, "window")
+ w, e := requireWebSocketField(msg.Data, "window")
if e != nil {
return nil, false, e
}
result, handled, err = s.Core().PERFORM(webview.TaskScreenshot{Window: w})
case "webview:scroll":
- w, e := wsRequire(msg.Data, "window")
+ w, e := requireWebSocketField(msg.Data, "window")
if e != nil {
return nil, false, e
}
@@ -352,43 +300,43 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) {
y, _ := msg.Data["y"].(float64)
result, handled, err = s.Core().PERFORM(webview.TaskScroll{Window: w, X: int(x), Y: int(y)})
case "webview:hover":
- w, e := wsRequire(msg.Data, "window")
+ w, e := requireWebSocketField(msg.Data, "window")
if e != nil {
return nil, false, e
}
- sel, e := wsRequire(msg.Data, "selector")
+ sel, e := requireWebSocketField(msg.Data, "selector")
if e != nil {
return nil, false, e
}
result, handled, err = s.Core().PERFORM(webview.TaskHover{Window: w, Selector: sel})
case "webview:select":
- w, e := wsRequire(msg.Data, "window")
+ w, e := requireWebSocketField(msg.Data, "window")
if e != nil {
return nil, false, e
}
- sel, e := wsRequire(msg.Data, "selector")
+ sel, e := requireWebSocketField(msg.Data, "selector")
if e != nil {
return nil, false, e
}
val, _ := msg.Data["value"].(string)
result, handled, err = s.Core().PERFORM(webview.TaskSelect{Window: w, Selector: sel, Value: val})
case "webview:check":
- w, e := wsRequire(msg.Data, "window")
+ w, e := requireWebSocketField(msg.Data, "window")
if e != nil {
return nil, false, e
}
- sel, e := wsRequire(msg.Data, "selector")
+ sel, e := requireWebSocketField(msg.Data, "selector")
if e != nil {
return nil, false, e
}
checked, _ := msg.Data["checked"].(bool)
result, handled, err = s.Core().PERFORM(webview.TaskCheck{Window: w, Selector: sel, Checked: checked})
case "webview:upload":
- w, e := wsRequire(msg.Data, "window")
+ w, e := requireWebSocketField(msg.Data, "window")
if e != nil {
return nil, false, e
}
- sel, e := wsRequire(msg.Data, "selector")
+ sel, e := requireWebSocketField(msg.Data, "selector")
if e != nil {
return nil, false, e
}
@@ -401,7 +349,7 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) {
}
result, handled, err = s.Core().PERFORM(webview.TaskUploadFile{Window: w, Selector: sel, Paths: paths})
case "webview:viewport":
- w, e := wsRequire(msg.Data, "window")
+ w, e := requireWebSocketField(msg.Data, "window")
if e != nil {
return nil, false, e
}
@@ -409,13 +357,13 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) {
height, _ := msg.Data["height"].(float64)
result, handled, err = s.Core().PERFORM(webview.TaskSetViewport{Window: w, Width: int(width), Height: int(height)})
case "webview:clear-console":
- w, e := wsRequire(msg.Data, "window")
+ w, e := requireWebSocketField(msg.Data, "window")
if e != nil {
return nil, false, e
}
result, handled, err = s.Core().PERFORM(webview.TaskClearConsole{Window: w})
case "webview:console":
- w, e := wsRequire(msg.Data, "window")
+ w, e := requireWebSocketField(msg.Data, "window")
if e != nil {
return nil, false, e
}
@@ -426,40 +374,40 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) {
}
result, handled, err = s.Core().QUERY(webview.QueryConsole{Window: w, Level: level, Limit: limit})
case "webview:query":
- w, e := wsRequire(msg.Data, "window")
+ w, e := requireWebSocketField(msg.Data, "window")
if e != nil {
return nil, false, e
}
- sel, e := wsRequire(msg.Data, "selector")
+ sel, e := requireWebSocketField(msg.Data, "selector")
if e != nil {
return nil, false, e
}
result, handled, err = s.Core().QUERY(webview.QuerySelector{Window: w, Selector: sel})
case "webview:query-all":
- w, e := wsRequire(msg.Data, "window")
+ w, e := requireWebSocketField(msg.Data, "window")
if e != nil {
return nil, false, e
}
- sel, e := wsRequire(msg.Data, "selector")
+ sel, e := requireWebSocketField(msg.Data, "selector")
if e != nil {
return nil, false, e
}
result, handled, err = s.Core().QUERY(webview.QuerySelectorAll{Window: w, Selector: sel})
case "webview:dom-tree":
- w, e := wsRequire(msg.Data, "window")
+ w, e := requireWebSocketField(msg.Data, "window")
if e != nil {
return nil, false, e
}
sel, _ := msg.Data["selector"].(string) // selector optional for dom-tree (defaults to root)
result, handled, err = s.Core().QUERY(webview.QueryDOMTree{Window: w, Selector: sel})
case "webview:url":
- w, e := wsRequire(msg.Data, "window")
+ w, e := requireWebSocketField(msg.Data, "window")
if e != nil {
return nil, false, e
}
result, handled, err = s.Core().QUERY(webview.QueryURL{Window: w})
case "webview:title":
- w, e := wsRequire(msg.Data, "window")
+ w, e := requireWebSocketField(msg.Data, "window")
if e != nil {
return nil, false, e
}
@@ -475,22 +423,25 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) {
func (s *Service) handleTrayAction(actionID string) {
switch actionID {
case "open-desktop":
- // Show all windows
infos := s.ListWindowInfos()
for _, info := range infos {
+ _, _, _ = s.Core().PERFORM(window.TaskSetVisibility{Name: info.Name, Visible: true})
_, _, _ = s.Core().PERFORM(window.TaskFocus{Name: info.Name})
}
case "close-desktop":
- // Hide all windows — future: add TaskHideWindow
+ infos := s.ListWindowInfos()
+ for _, info := range infos {
+ _, _, _ = s.Core().PERFORM(window.TaskSetVisibility{Name: info.Name, Visible: false})
+ }
case "env-info":
// Query environment info via IPC and show as dialog
result, handled, _ := s.Core().QUERY(environment.QueryInfo{})
if handled {
info := result.(environment.EnvironmentInfo)
- details := fmt.Sprintf("OS: %s\nArch: %s\nPlatform: %s %s",
- info.OS, info.Arch, info.Platform.Name, info.Platform.Version)
+ details := "OS: " + info.OS + "\nArch: " + info.Arch + "\nPlatform: " +
+ info.Platform.Name + " " + info.Platform.Version
_, _, _ = s.Core().PERFORM(dialog.TaskMessageDialog{
- Opts: dialog.MessageDialogOptions{
+ Options: dialog.MessageDialogOptions{
Type: dialog.DialogInfo, Title: "Environment",
Message: details, Buttons: []string{"OK"},
},
@@ -512,23 +463,23 @@ func guiConfigPath() string {
}
func (s *Service) loadConfig() {
- if s.cfg != nil {
+ if s.configFile != nil {
return // Already loaded (e.g., via loadConfigFrom in tests)
}
s.loadConfigFrom(guiConfigPath())
}
func (s *Service) loadConfigFrom(path string) {
- cfg, err := config.New(config.WithPath(path))
+ configFile, err := config.New(config.WithPath(path))
if err != nil {
- // Non-critical — continue with empty configData
+ // Non-critical: continue with empty configData
return
}
- s.cfg = cfg
+ s.configFile = configFile
for _, section := range []string{"window", "systray", "menu"} {
var data map[string]any
- if err := cfg.Get(section, &data); err == nil && data != nil {
+ if err := configFile.Get(section, &data); err == nil && data != nil {
s.configData[section] = data
}
}
@@ -550,16 +501,16 @@ func (s *Service) handleConfigQuery(c *core.Core, q core.Query) (any, bool, erro
func (s *Service) handleConfigTask(c *core.Core, t core.Task) (any, bool, error) {
switch t := t.(type) {
case window.TaskSaveConfig:
- s.configData["window"] = t.Value
- s.persistSection("window", t.Value)
+ s.configData["window"] = t.Config
+ s.persistSection("window", t.Config)
return nil, true, nil
case systray.TaskSaveConfig:
- s.configData["systray"] = t.Value
- s.persistSection("systray", t.Value)
+ s.configData["systray"] = t.Config
+ s.persistSection("systray", t.Config)
return nil, true, nil
case menu.TaskSaveConfig:
- s.configData["menu"] = t.Value
- s.persistSection("menu", t.Value)
+ s.configData["menu"] = t.Config
+ s.persistSection("menu", t.Config)
return nil, true, nil
default:
return nil, false, nil
@@ -567,29 +518,32 @@ func (s *Service) handleConfigTask(c *core.Core, t core.Task) (any, bool, error)
}
func (s *Service) persistSection(key string, value map[string]any) {
- if s.cfg == nil {
+ if s.configFile == nil {
return
}
- _ = s.cfg.Set(key, value)
- _ = s.cfg.Commit()
+ _ = s.configFile.Set(key, value)
+ _ = s.configFile.Commit()
}
// --- Service accessors ---
-// windowService returns the window service from Core, or nil if not registered.
-func (s *Service) windowService() *window.Service {
- svc, err := core.ServiceFor[*window.Service](s.Core(), "window")
+func (s *Service) performWindowTask(operation string, task core.Task) (any, error) {
+ result, handled, err := s.Core().PERFORM(task)
if err != nil {
- return nil
+ return nil, err
}
- return svc
+ if !handled {
+ return nil, coreerr.E(operation, "window service not available", nil)
+ }
+ return result, nil
}
// --- Window Management (delegates via IPC) ---
-// OpenWindow creates a new window via IPC.
-func (s *Service) OpenWindow(opts ...window.WindowOption) error {
- _, _, err := s.Core().PERFORM(window.TaskOpenWindow{Opts: opts})
+// OpenWindow opens a window using manager defaults.
+// Example: s.OpenWindow(window.Window{})
+func (s *Service) OpenWindow(spec window.Window) error {
+ _, err := s.performWindowTask("display.OpenWindow", window.TaskOpenWindow{Window: spec})
return err
}
@@ -600,7 +554,7 @@ func (s *Service) GetWindowInfo(name string) (*window.WindowInfo, error) {
return nil, err
}
if !handled {
- return nil, fmt.Errorf("window service not available")
+ return nil, coreerr.E("display.GetWindowInfo", "window service not available", nil)
}
info, _ := result.(*window.WindowInfo)
return info, nil
@@ -610,152 +564,97 @@ func (s *Service) GetWindowInfo(name string) (*window.WindowInfo, error) {
func (s *Service) ListWindowInfos() []window.WindowInfo {
result, handled, _ := s.Core().QUERY(window.QueryWindowList{})
if !handled {
- return nil
+ return []window.WindowInfo{}
}
list, _ := result.([]window.WindowInfo)
return list
}
-// SetWindowPosition moves a window via IPC.
+// Example: s.SetWindowPosition("editor", 100, 200)
+// Use SetWindowBounds when you are changing position and size together.
func (s *Service) SetWindowPosition(name string, x, y int) error {
- _, _, err := s.Core().PERFORM(window.TaskSetPosition{Name: name, X: x, Y: y})
+ _, err := s.performWindowTask("display.SetWindowPosition", window.TaskSetPosition{Name: name, X: x, Y: y})
return err
}
-// SetWindowSize resizes a window via IPC.
+// Example: s.SetWindowSize("editor", 1280, 720)
+// Use SetWindowBounds when you are changing position and size together.
func (s *Service) SetWindowSize(name string, width, height int) error {
- _, _, err := s.Core().PERFORM(window.TaskSetSize{Name: name, W: width, H: height})
+ _, err := s.performWindowTask("display.SetWindowSize", window.TaskSetSize{Name: name, Width: width, Height: height})
return err
}
-// SetWindowBounds sets both position and size of a window via IPC.
+// Example: s.SetWindowBounds("editor", 100, 200, 1280, 720)
func (s *Service) SetWindowBounds(name string, x, y, width, height int) error {
- if _, _, err := s.Core().PERFORM(window.TaskSetPosition{Name: name, X: x, Y: y}); err != nil {
- return err
- }
- _, _, err := s.Core().PERFORM(window.TaskSetSize{Name: name, W: width, H: height})
+ _, err := s.performWindowTask("display.SetWindowBounds", window.TaskSetBounds{
+ Name: name, X: x, Y: y, Width: width, Height: height,
+ })
return err
}
// MaximizeWindow maximizes a window via IPC.
func (s *Service) MaximizeWindow(name string) error {
- _, _, err := s.Core().PERFORM(window.TaskMaximise{Name: name})
+ _, err := s.performWindowTask("display.MaximizeWindow", window.TaskMaximize{Name: name})
return err
}
// MinimizeWindow minimizes a window via IPC.
func (s *Service) MinimizeWindow(name string) error {
- _, _, err := s.Core().PERFORM(window.TaskMinimise{Name: name})
+ _, err := s.performWindowTask("display.MinimizeWindow", window.TaskMinimize{Name: name})
return err
}
// FocusWindow brings a window to the front via IPC.
func (s *Service) FocusWindow(name string) error {
- _, _, err := s.Core().PERFORM(window.TaskFocus{Name: name})
+ _, err := s.performWindowTask("display.FocusWindow", window.TaskFocus{Name: name})
return err
}
// CloseWindow closes a window via IPC.
func (s *Service) CloseWindow(name string) error {
- _, _, err := s.Core().PERFORM(window.TaskCloseWindow{Name: name})
+ _, err := s.performWindowTask("display.CloseWindow", window.TaskCloseWindow{Name: name})
return err
}
// RestoreWindow restores a maximized/minimized window.
-// Uses direct Manager access (no IPC task for restore yet).
func (s *Service) RestoreWindow(name string) error {
- ws := s.windowService()
- if ws == nil {
- return fmt.Errorf("window service not available")
- }
- pw, ok := ws.Manager().Get(name)
- if !ok {
- return fmt.Errorf("window not found: %s", name)
- }
- pw.Restore()
- return nil
+ _, err := s.performWindowTask("display.RestoreWindow", window.TaskRestore{Name: name})
+ return err
}
// SetWindowVisibility shows or hides a window.
-// Uses direct Manager access (no IPC task for visibility yet).
func (s *Service) SetWindowVisibility(name string, visible bool) error {
- ws := s.windowService()
- if ws == nil {
- return fmt.Errorf("window service not available")
- }
- pw, ok := ws.Manager().Get(name)
- if !ok {
- return fmt.Errorf("window not found: %s", name)
- }
- pw.SetVisibility(visible)
- return nil
+ _, err := s.performWindowTask("display.SetWindowVisibility", window.TaskSetVisibility{Name: name, Visible: visible})
+ return err
}
// SetWindowAlwaysOnTop sets whether a window stays on top.
-// Uses direct Manager access (no IPC task for always-on-top yet).
func (s *Service) SetWindowAlwaysOnTop(name string, alwaysOnTop bool) error {
- ws := s.windowService()
- if ws == nil {
- return fmt.Errorf("window service not available")
- }
- pw, ok := ws.Manager().Get(name)
- if !ok {
- return fmt.Errorf("window not found: %s", name)
- }
- pw.SetAlwaysOnTop(alwaysOnTop)
- return nil
+ _, err := s.performWindowTask("display.SetWindowAlwaysOnTop", window.TaskSetAlwaysOnTop{Name: name, AlwaysOnTop: alwaysOnTop})
+ return err
}
// SetWindowTitle changes a window's title.
-// Uses direct Manager access (no IPC task for title yet).
func (s *Service) SetWindowTitle(name string, title string) error {
- ws := s.windowService()
- if ws == nil {
- return fmt.Errorf("window service not available")
- }
- pw, ok := ws.Manager().Get(name)
- if !ok {
- return fmt.Errorf("window not found: %s", name)
- }
- pw.SetTitle(title)
- return nil
+ _, err := s.performWindowTask("display.SetWindowTitle", window.TaskSetTitle{Name: name, Title: title})
+ return err
}
// SetWindowFullscreen sets a window to fullscreen mode.
-// Uses direct Manager access (no IPC task for fullscreen yet).
func (s *Service) SetWindowFullscreen(name string, fullscreen bool) error {
- ws := s.windowService()
- if ws == nil {
- return fmt.Errorf("window service not available")
- }
- pw, ok := ws.Manager().Get(name)
- if !ok {
- return fmt.Errorf("window not found: %s", name)
- }
- if fullscreen {
- pw.Fullscreen()
- } else {
- pw.UnFullscreen()
- }
- return nil
+ _, err := s.performWindowTask("display.SetWindowFullscreen", window.TaskFullscreen{Name: name, Fullscreen: fullscreen})
+ return err
}
// SetWindowBackgroundColour sets the background colour of a window.
-// Uses direct Manager access (no IPC task for background colour yet).
func (s *Service) SetWindowBackgroundColour(name string, r, g, b, a uint8) error {
- ws := s.windowService()
- if ws == nil {
- return fmt.Errorf("window service not available")
- }
- pw, ok := ws.Manager().Get(name)
- if !ok {
- return fmt.Errorf("window not found: %s", name)
- }
- pw.SetBackgroundColour(r, g, b, a)
- return nil
+ _, err := s.performWindowTask("display.SetWindowBackgroundColour", window.TaskSetBackgroundColour{
+ Name: name, Red: r, Green: g, Blue: b, Alpha: a,
+ })
+ return err
}
-// GetFocusedWindow returns the name of the currently focused window.
+// Example: focused := s.GetFocusedWindow()
func (s *Service) GetFocusedWindow() string {
infos := s.ListWindowInfos()
for _, info := range infos {
@@ -766,189 +665,130 @@ func (s *Service) GetFocusedWindow() string {
return ""
}
-// GetWindowTitle returns the title of a window by name.
+// Example: title, err := s.GetWindowTitle("editor")
func (s *Service) GetWindowTitle(name string) (string, error) {
info, err := s.GetWindowInfo(name)
if err != nil {
return "", err
}
if info == nil {
- return "", fmt.Errorf("window not found: %s", name)
+ return "", coreerr.E("display.GetWindowTitle", "window not found: "+name, nil)
}
return info.Title, nil
}
-// ResetWindowState clears saved window positions.
+// Example: s.ResetWindowState()
func (s *Service) ResetWindowState() error {
- ws := s.windowService()
- if ws != nil {
- ws.Manager().State().Clear()
- }
- return nil
+ _, err := s.performWindowTask("display.ResetWindowState", window.TaskResetWindowState{})
+ return err
}
-// GetSavedWindowStates returns all saved window states.
+// Example: states := s.GetSavedWindowStates()
func (s *Service) GetSavedWindowStates() map[string]window.WindowState {
- ws := s.windowService()
- if ws == nil {
- return nil
+ result, handled, _ := s.Core().QUERY(window.QuerySavedWindowStates{})
+ if !handled {
+ return map[string]window.WindowState{}
}
- result := make(map[string]window.WindowState)
- for _, name := range ws.Manager().State().ListStates() {
- if state, ok := ws.Manager().State().GetState(name); ok {
- result[name] = state
- }
+ saved, _ := result.(map[string]window.WindowState)
+ if saved == nil {
+ return map[string]window.WindowState{}
}
- return result
+ out := make(map[string]window.WindowState, len(saved))
+ for name, state := range saved {
+ out[name] = state
+ }
+ return out
}
-// CreateWindowOptions contains options for creating a new window.
-type CreateWindowOptions 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"`
-}
-
-// CreateWindow creates a new window with the specified options.
-func (s *Service) CreateWindow(opts CreateWindowOptions) (*window.WindowInfo, error) {
- if opts.Name == "" {
- return nil, fmt.Errorf("window name is required")
+// CreateWindow opens a named window and returns its info.
+// Example: s.CreateWindow(window.Window{Name: "editor", Title: "Editor", URL: "/#/editor"})
+func (s *Service) CreateWindow(spec window.Window) (*window.WindowInfo, error) {
+ if spec.Name == "" {
+ return nil, coreerr.E("display.CreateWindow", "window name is required", nil)
}
- result, _, err := s.Core().PERFORM(window.TaskOpenWindow{
- Opts: []window.WindowOption{
- window.WithName(opts.Name),
- window.WithTitle(opts.Title),
- window.WithURL(opts.URL),
- window.WithSize(opts.Width, opts.Height),
- window.WithPosition(opts.X, opts.Y),
- },
+ result, err := s.performWindowTask("display.CreateWindow", window.TaskOpenWindow{
+ Window: spec,
})
if err != nil {
return nil, err
}
- info := result.(window.WindowInfo)
+ info, ok := result.(window.WindowInfo)
+ if !ok {
+ return nil, coreerr.E("display.CreateWindow", "unexpected result type from window create task", nil)
+ }
return &info, nil
}
// --- Layout delegation ---
-// SaveLayout saves the current window arrangement as a named layout.
+// Example: s.SaveLayout("coding")
func (s *Service) SaveLayout(name string) error {
- ws := s.windowService()
- if ws == nil {
- return fmt.Errorf("window service not available")
- }
- states := make(map[string]window.WindowState)
- for _, n := range ws.Manager().List() {
- if pw, ok := ws.Manager().Get(n); ok {
- x, y := pw.Position()
- w, h := pw.Size()
- states[n] = window.WindowState{X: x, Y: y, Width: w, Height: h, Maximized: pw.IsMaximised()}
- }
- }
- return ws.Manager().Layout().SaveLayout(name, states)
+ _, err := s.performWindowTask("display.SaveLayout", window.TaskSaveLayout{Name: name})
+ return err
}
-// RestoreLayout applies a saved layout.
+// Example: s.RestoreLayout("coding")
func (s *Service) RestoreLayout(name string) error {
- ws := s.windowService()
- if ws == nil {
- return fmt.Errorf("window service not available")
- }
- layout, ok := ws.Manager().Layout().GetLayout(name)
- if !ok {
- return fmt.Errorf("layout not found: %s", name)
- }
- for wName, state := range layout.Windows {
- if pw, ok := ws.Manager().Get(wName); ok {
- pw.SetPosition(state.X, state.Y)
- pw.SetSize(state.Width, state.Height)
- if state.Maximized {
- pw.Maximise()
- } else {
- pw.Restore()
- }
- }
- }
- return nil
+ _, err := s.performWindowTask("display.RestoreLayout", window.TaskRestoreLayout{Name: name})
+ return err
}
// ListLayouts returns all saved layout names with metadata.
func (s *Service) ListLayouts() []window.LayoutInfo {
- ws := s.windowService()
- if ws == nil {
- return nil
+ result, handled, _ := s.Core().QUERY(window.QueryLayoutList{})
+ if !handled {
+ return []window.LayoutInfo{}
}
- return ws.Manager().Layout().ListLayouts()
+ layouts, _ := result.([]window.LayoutInfo)
+ return layouts
}
-// DeleteLayout removes a saved layout by name.
+// Example: s.DeleteLayout("coding")
func (s *Service) DeleteLayout(name string) error {
- ws := s.windowService()
- if ws == nil {
- return fmt.Errorf("window service not available")
- }
- ws.Manager().Layout().DeleteLayout(name)
- return nil
+ _, err := s.performWindowTask("display.DeleteLayout", window.TaskDeleteLayout{Name: name})
+ return err
}
// GetLayout returns a specific layout by name.
func (s *Service) GetLayout(name string) *window.Layout {
- ws := s.windowService()
- if ws == nil {
+ result, handled, _ := s.Core().QUERY(window.QueryLayoutGet{Name: name})
+ if !handled {
return nil
}
- layout, ok := ws.Manager().Layout().GetLayout(name)
- if !ok {
- return nil
- }
- return &layout
+ layout, _ := result.(*window.Layout)
+ return layout
}
// --- Tiling/snapping delegation ---
-// TileWindows arranges windows in a tiled layout.
+// Example: s.TileWindows(window.TileModeLeftRight, []string{"editor", "terminal"})
func (s *Service) TileWindows(mode window.TileMode, windowNames []string) error {
- ws := s.windowService()
- if ws == nil {
- return fmt.Errorf("window service not available")
- }
- return ws.Manager().TileWindows(mode, windowNames, 1920, 1080) // TODO: use actual screen size
+ _, err := s.performWindowTask("display.TileWindows", window.TaskTileWindows{Mode: mode.String(), Windows: windowNames})
+ return err
}
-// SnapWindow snaps a window to a screen edge or corner.
+// Example: s.SnapWindow("editor", window.SnapRight)
func (s *Service) SnapWindow(name string, position window.SnapPosition) error {
- ws := s.windowService()
- if ws == nil {
- return fmt.Errorf("window service not available")
- }
- return ws.Manager().SnapWindow(name, position, 1920, 1080) // TODO: use actual screen size
+ _, err := s.performWindowTask("display.SnapWindow", window.TaskSnapWindow{Name: name, Position: position.String()})
+ return err
}
-// StackWindows arranges windows in a cascade pattern.
+// Example: s.StackWindows([]string{"editor", "terminal"}, 24, 24)
func (s *Service) StackWindows(windowNames []string, offsetX, offsetY int) error {
- ws := s.windowService()
- if ws == nil {
- return fmt.Errorf("window service not available")
- }
- return ws.Manager().StackWindows(windowNames, offsetX, offsetY)
+ _, err := s.performWindowTask("display.StackWindows", window.TaskStackWindows{Windows: windowNames, OffsetX: offsetX, OffsetY: offsetY})
+ return err
}
-// ApplyWorkflowLayout applies a predefined layout for a specific workflow.
+// Example: s.ApplyWorkflowLayout(window.WorkflowCoding)
func (s *Service) ApplyWorkflowLayout(workflow window.WorkflowLayout) error {
- ws := s.windowService()
- if ws == nil {
- return fmt.Errorf("window service not available")
- }
- return ws.Manager().ApplyWorkflow(workflow, ws.Manager().List(), 1920, 1080)
+ _, err := s.performWindowTask("display.ApplyWorkflowLayout", window.TaskApplyWorkflow{
+ Workflow: workflow.String(),
+ })
+ return err
}
-// GetEventManager returns the event manager for WebSocket event subscriptions.
-func (s *Service) GetEventManager() *WSEventManager {
+// GetWebSocketEventManager returns the event manager for WebSocket event subscriptions.
+func (s *Service) GetWebSocketEventManager() *WebSocketEventManager {
return s.events
}
@@ -992,42 +832,40 @@ func ptr[T any](v T) *T { return &v }
// --- Menu handler methods ---
func (s *Service) handleNewWorkspace() {
- _, _, _ = s.Core().PERFORM(window.TaskOpenWindow{
- Opts: []window.WindowOption{
- window.WithName("workspace-new"),
- window.WithTitle("New Workspace"),
- window.WithURL("/workspace/new"),
- window.WithSize(500, 400),
- },
+ _, _ = s.CreateWindow(window.Window{
+ Name: "workspace-new",
+ Title: "New Workspace",
+ URL: "/workspace/new",
+ Width: 500,
+ Height: 400,
})
}
func (s *Service) handleListWorkspaces() {
- ws := s.Core().Service("workspace")
- if ws == nil {
+ workspaceService := s.Core().Service("workspace")
+ if workspaceService == nil {
return
}
- lister, ok := ws.(interface{ ListWorkspaces() []string })
+ workspaceLister, ok := workspaceService.(interface{ ListWorkspaces() []string })
if !ok {
return
}
- _ = lister.ListWorkspaces()
+ _ = workspaceLister.ListWorkspaces()
}
func (s *Service) handleNewFile() {
- _, _, _ = s.Core().PERFORM(window.TaskOpenWindow{
- Opts: []window.WindowOption{
- window.WithName("editor"),
- window.WithTitle("New File - Editor"),
- window.WithURL("/#/developer/editor?new=true"),
- window.WithSize(1200, 800),
- },
+ _, _ = s.CreateWindow(window.Window{
+ Name: "editor",
+ Title: "New File - Editor",
+ URL: "/#/developer/editor?new=true",
+ Width: 1200,
+ Height: 800,
})
}
func (s *Service) handleOpenFile() {
result, handled, err := s.Core().PERFORM(dialog.TaskOpenFile{
- Opts: dialog.OpenFileOptions{
+ Options: dialog.OpenFileOptions{
Title: "Open File",
AllowMultiple: false,
},
@@ -1039,35 +877,32 @@ func (s *Service) handleOpenFile() {
if !ok || len(paths) == 0 {
return
}
- _, _, _ = s.Core().PERFORM(window.TaskOpenWindow{
- Opts: []window.WindowOption{
- window.WithName("editor"),
- window.WithTitle(paths[0] + " - Editor"),
- window.WithURL("/#/developer/editor?file=" + paths[0]),
- window.WithSize(1200, 800),
- },
+ _, _ = s.CreateWindow(window.Window{
+ Name: "editor",
+ Title: paths[0] + " - Editor",
+ URL: "/#/developer/editor?file=" + paths[0],
+ Width: 1200,
+ Height: 800,
})
}
func (s *Service) handleSaveFile() { _ = s.Core().ACTION(ActionIDECommand{Command: "save"}) }
func (s *Service) handleOpenEditor() {
- _, _, _ = s.Core().PERFORM(window.TaskOpenWindow{
- Opts: []window.WindowOption{
- window.WithName("editor"),
- window.WithTitle("Editor"),
- window.WithURL("/#/developer/editor"),
- window.WithSize(1200, 800),
- },
+ _, _ = s.CreateWindow(window.Window{
+ Name: "editor",
+ Title: "Editor",
+ URL: "/#/developer/editor",
+ Width: 1200,
+ Height: 800,
})
}
func (s *Service) handleOpenTerminal() {
- _, _, _ = s.Core().PERFORM(window.TaskOpenWindow{
- Opts: []window.WindowOption{
- window.WithName("terminal"),
- window.WithTitle("Terminal"),
- window.WithURL("/#/developer/terminal"),
- window.WithSize(800, 500),
- },
+ _, _ = s.CreateWindow(window.Window{
+ Name: "terminal",
+ Title: "Terminal",
+ URL: "/#/developer/terminal",
+ Width: 800,
+ Height: 500,
})
}
func (s *Service) handleRun() { _ = s.Core().ACTION(ActionIDECommand{Command: "run"}) }
diff --git a/pkg/display/display_test.go b/pkg/display/display_test.go
index 03eb1d2..0622748 100644
--- a/pkg/display/display_test.go
+++ b/pkg/display/display_test.go
@@ -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)
diff --git a/pkg/display/docs/backend.md b/pkg/display/docs/backend.md
index 3e67e43..ccb08b3 100644
--- a/pkg/display/docs/backend.md
+++ b/pkg/display/docs/backend.md
@@ -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`.
diff --git a/pkg/display/docs/development.md b/pkg/display/docs/development.md
index 720fa81..4a0e3dc 100644
--- a/pkg/display/docs/development.md
+++ b/pkg/display/docs/development.md
@@ -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/...
```
diff --git a/pkg/display/docs/overview.md b/pkg/display/docs/overview.md
index ef743b2..a5f69ff 100644
--- a/pkg/display/docs/overview.md
+++ b/pkg/display/docs/overview.md
@@ -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.
diff --git a/pkg/display/events.go b/pkg/display/events.go
index 823872f..7a7c241 100644
--- a/pkg/display/events.go
+++ b/pkg/display/events.go
@@ -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
}
diff --git a/pkg/display/messages.go b/pkg/display/messages.go
index 43d4e3f..077f7cf 100644
--- a/pkg/display/messages.go
+++ b/pkg/display/messages.go
@@ -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"
diff --git a/pkg/dock/register.go b/pkg/dock/register.go
index 1123927..96ec94d 100644
--- a/pkg/dock/register.go
+++ b/pkg/dock/register.go
@@ -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{
diff --git a/pkg/dock/service.go b/pkg/dock/service.go
index 260ff0a..346ef95 100644
--- a/pkg/dock/service.go
+++ b/pkg/dock/service.go
@@ -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:
diff --git a/pkg/keybinding/messages.go b/pkg/keybinding/messages.go
index 7f037f3..a168806 100644
--- a/pkg/keybinding/messages.go
+++ b/pkg/keybinding/messages.go
@@ -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"`
}
diff --git a/pkg/keybinding/register.go b/pkg/keybinding/register.go
index 417819e..091cbfa 100644
--- a/pkg/keybinding/register.go
+++ b/pkg/keybinding/register.go
@@ -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
}
}
diff --git a/pkg/keybinding/service.go b/pkg/keybinding/service.go
index 048c259..3afd23b 100644
--- a/pkg/keybinding/service.go
+++ b/pkg/keybinding/service.go
@@ -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
}
diff --git a/pkg/keybinding/service_test.go b/pkg/keybinding/service_test.go
index 14749f2..b586e07 100644
--- a/pkg/keybinding/service_test.go
+++ b/pkg/keybinding/service_test.go
@@ -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) {
diff --git a/pkg/lifecycle/register.go b/pkg/lifecycle/register.go
index 90e5d40..fcf43ea 100644
--- a/pkg/lifecycle/register.go
+++ b/pkg/lifecycle/register.go
@@ -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{
diff --git a/pkg/lifecycle/service.go b/pkg/lifecycle/service.go
index 41e7ca8..3ba4255 100644
--- a/pkg/lifecycle/service.go
+++ b/pkg/lifecycle/service.go
@@ -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
}
diff --git a/pkg/mcp/mcp_test.go b/pkg/mcp/mcp_test.go
index d3a3453..48aa538 100644
--- a/pkg/mcp/mcp_test.go
+++ b/pkg/mcp/mcp_test.go
@@ -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
diff --git a/pkg/mcp/subsystem.go b/pkg/mcp/subsystem.go
index b567d1b..7aa0ee7 100644
--- a/pkg/mcp/subsystem.go
+++ b/pkg/mcp/subsystem.go
@@ -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}
}
diff --git a/pkg/mcp/tools_clipboard.go b/pkg/mcp/tools_clipboard.go
index 827586a..82aa435 100644
--- a/pkg/mcp/tools_clipboard.go
+++ b/pkg/mcp/tools_clipboard.go
@@ -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
}
diff --git a/pkg/mcp/tools_contextmenu.go b/pkg/mcp/tools_contextmenu.go
index 74e9f8b..d6da3a5 100644
--- a/pkg/mcp/tools_contextmenu.go
+++ b/pkg/mcp/tools_contextmenu.go
@@ -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
}
diff --git a/pkg/mcp/tools_dialog.go b/pkg/mcp/tools_dialog.go
index 06bdf66..aee701d 100644
--- a/pkg/mcp/tools_dialog.go
+++ b/pkg/mcp/tools_dialog.go
@@ -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
}
diff --git a/pkg/mcp/tools_environment.go b/pkg/mcp/tools_environment.go
index 87eb0df..c8fc831 100644
--- a/pkg/mcp/tools_environment.go
+++ b/pkg/mcp/tools_environment.go
@@ -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
}
diff --git a/pkg/mcp/tools_layout.go b/pkg/mcp/tools_layout.go
index 24ec8fa..be2f3f9 100644
--- a/pkg/mcp/tools_layout.go
+++ b/pkg/mcp/tools_layout.go
@@ -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)
}
diff --git a/pkg/mcp/tools_notification.go b/pkg/mcp/tools_notification.go
index 259e59f..11e8af3 100644
--- a/pkg/mcp/tools_notification.go
+++ b/pkg/mcp/tools_notification.go
@@ -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
}
diff --git a/pkg/mcp/tools_screen.go b/pkg/mcp/tools_screen.go
index 8a276b9..7f86e7e 100644
--- a/pkg/mcp/tools_screen.go
+++ b/pkg/mcp/tools_screen.go
@@ -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)
}
diff --git a/pkg/mcp/tools_tray.go b/pkg/mcp/tools_tray.go
index 0cbad22..d5efb45 100644
--- a/pkg/mcp/tools_tray.go
+++ b/pkg/mcp/tools_tray.go
@@ -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
}
diff --git a/pkg/mcp/tools_webview.go b/pkg/mcp/tools_webview.go
index b598a4b..923fe36 100644
--- a/pkg/mcp/tools_webview.go
+++ b/pkg/mcp/tools_webview.go
@@ -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
}
diff --git a/pkg/mcp/tools_window.go b/pkg/mcp/tools_window.go
index e5ac73f..0b53af0 100644
--- a/pkg/mcp/tools_window.go
+++ b/pkg/mcp/tools_window.go
@@ -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)
}
diff --git a/pkg/menu/messages.go b/pkg/menu/messages.go
index 61c8de5..55aed57 100644
--- a/pkg/menu/messages.go
+++ b/pkg/menu/messages.go
@@ -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 }
diff --git a/pkg/menu/register.go b/pkg/menu/register.go
index acb4b88..59dbae8 100644
--- a/pkg/menu/register.go
+++ b/pkg/menu/register.go
@@ -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{
diff --git a/pkg/menu/service.go b/pkg/menu/service.go
index 2e8ac26..1a3f838 100644
--- a/pkg/menu/service.go
+++ b/pkg/menu/service.go
@@ -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
}
diff --git a/pkg/notification/messages.go b/pkg/notification/messages.go
index e0df1ea..1cc10f9 100644
--- a/pkg/notification/messages.go
+++ b/pkg/notification/messages.go
@@ -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 }
diff --git a/pkg/notification/platform.go b/pkg/notification/platform.go
index f0d9963..954a5af 100644
--- a/pkg/notification/platform.go
+++ b/pkg/notification/platform.go
@@ -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)
}
diff --git a/pkg/notification/service.go b/pkg/notification/service.go
index df43b6d..7dc412b 100644
--- a/pkg/notification/service.go
+++ b/pkg/notification/service.go
@@ -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"},
},
diff --git a/pkg/notification/service_test.go b/pkg/notification/service_test.go
index 33db648..8689ddf 100644
--- a/pkg/notification/service_test.go
+++ b/pkg/notification/service_test.go
@@ -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
diff --git a/pkg/systray/menu.go b/pkg/systray/menu.go
index 3032a6d..c56ebc9 100644
--- a/pkg/systray/menu.go
+++ b/pkg/systray/menu.go
@@ -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.
diff --git a/pkg/systray/messages.go b/pkg/systray/messages.go
index 4fc5bfe..6855e22 100644
--- a/pkg/systray/messages.go
+++ b/pkg/systray/messages.go
@@ -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 }
diff --git a/pkg/systray/mock_platform.go b/pkg/systray/mock_platform.go
index 0f3f6e1..c92a58f 100644
--- a/pkg/systray/mock_platform.go
+++ b/pkg/systray/mock_platform.go
@@ -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 }
diff --git a/pkg/systray/mock_test.go b/pkg/systray/mock_test.go
index 9082805..56f35cf 100644
--- a/pkg/systray/mock_test.go
+++ b/pkg/systray/mock_test.go
@@ -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 }
diff --git a/pkg/systray/platform.go b/pkg/systray/platform.go
index 1d76ec5..b749422 100644
--- a/pkg/systray/platform.go
+++ b/pkg/systray/platform.go
@@ -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.
diff --git a/pkg/systray/register.go b/pkg/systray/register.go
index b4d133b..055f35c 100644
--- a/pkg/systray/register.go
+++ b/pkg/systray/register.go
@@ -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{
diff --git a/pkg/systray/service.go b/pkg/systray/service.go
index 70eaa04..f585e7e 100644
--- a/pkg/systray/service.go
+++ b/pkg/systray/service.go
@@ -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
}
diff --git a/pkg/systray/tray.go b/pkg/systray/tray.go
index 05ffcdf..8d2e108 100644
--- a/pkg/systray/tray.go
+++ b/pkg/systray/tray.go
@@ -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
diff --git a/pkg/systray/tray_test.go b/pkg/systray/tray_test.go
index f802828..6de6d39 100644
--- a/pkg/systray/tray_test.go
+++ b/pkg/systray/tray_test.go
@@ -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])
+}
diff --git a/pkg/systray/wails.go b/pkg/systray/wails.go
index cbd9ed2..47b6982 100644
--- a/pkg/systray/wails.go
+++ b/pkg/systray/wails.go
@@ -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()}
-}
diff --git a/pkg/webview/service.go b/pkg/webview/service.go
index 6713174..485a0f1 100644
--- a/pkg/webview/service.go
+++ b/pkg/webview/service.go
@@ -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 {
diff --git a/pkg/webview/service_test.go b/pkg/webview/service_test.go
index 45a8f20..ec1ef70 100644
--- a/pkg/webview/service_test.go
+++ b/pkg/webview/service_test.go
@@ -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"})
diff --git a/pkg/window/layout.go b/pkg/window/layout.go
index 545a99f..2871937 100644
--- a/pkg/window/layout.go
+++ b/pkg/window/layout.go
@@ -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
}
diff --git a/pkg/window/messages.go b/pkg/window/messages.go
index b5d1a13..5ce7b38 100644
--- a/pkg/window/messages.go
+++ b/pkg/window/messages.go
@@ -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"`
}
diff --git a/pkg/window/mock_platform.go b/pkg/window/mock_platform.go
index 9dde9a6..2ba1138 100644
--- a/pkg/window/mock_platform.go
+++ b/pkg/window/mock_platform.go
@@ -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)
}
diff --git a/pkg/window/mock_test.go b/pkg/window/mock_test.go
index 72d54ca..1559548 100644
--- a/pkg/window/mock_test.go
+++ b/pkg/window/mock_test.go
@@ -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)
}
diff --git a/pkg/window/options.go b/pkg/window/options.go
deleted file mode 100644
index b677617..0000000
--- a/pkg/window/options.go
+++ /dev/null
@@ -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 }
-}
diff --git a/pkg/window/persistence_test.go b/pkg/window/persistence_test.go
new file mode 100644
index 0000000..d03442a
--- /dev/null
+++ b/pkg/window/persistence_test.go
@@ -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)
+}
diff --git a/pkg/window/platform.go b/pkg/window/platform.go
index ae4e2e6..1e54a26 100644
--- a/pkg/window/platform.go
+++ b/pkg/window/platform.go
@@ -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)
diff --git a/pkg/window/register.go b/pkg/window/register.go
index 63812f1..850b57a 100644
--- a/pkg/window/register.go
+++ b/pkg/window/register.go
@@ -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{
diff --git a/pkg/window/service.go b/pkg/window/service.go
index 040ab95..9a5c662 100644
--- a/pkg/window/service.go
+++ b/pkg/window/service.go
@@ -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.
diff --git a/pkg/window/service_screen_test.go b/pkg/window/service_screen_test.go
new file mode 100644
index 0000000..4dee1f8
--- /dev/null
+++ b/pkg/window/service_screen_test.go
@@ -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)
+}
diff --git a/pkg/window/service_test.go b/pkg/window/service_test.go
index 1911044..6239b58 100644
--- a/pkg/window/service_test.go
+++ b/pkg/window/service_test.go
@@ -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)
+}
diff --git a/pkg/window/state.go b/pkg/window/state.go
index 3523cfe..de838d2 100644
--- a/pkg/window/state.go
+++ b/pkg/window/state.go
@@ -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()
}
diff --git a/pkg/window/tiling.go b/pkg/window/tiling.go
index 40669fe..fa88f56 100644
--- a/pkg/window/tiling.go
+++ b/pkg/window/tiling.go
@@ -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
}
diff --git a/pkg/window/wails.go b/pkg/window/wails.go
index 1d2a722..2e90500 100644
--- a/pkg/window/wails.go
+++ b/pkg/window/wails.go
@@ -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)
-
diff --git a/pkg/window/window.go b/pkg/window/window.go
index 3692fe8..71455c9 100644
--- a/pkg/window/window.go
+++ b/pkg/window/window.go
@@ -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
}
diff --git a/pkg/window/window_test.go b/pkg/window/window_test.go
index 44d1f09..ed241c7 100644
--- a/pkg/window/window_test.go
+++ b/pkg/window/window_test.go
@@ -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)
+}
diff --git a/stubs/wails/go.mod b/stubs/wails/go.mod
new file mode 100644
index 0000000..7dcb832
--- /dev/null
+++ b/stubs/wails/go.mod
@@ -0,0 +1,3 @@
+module github.com/wailsapp/wails/v3
+
+go 1.26.0
diff --git a/stubs/wails/pkg/application/application.go b/stubs/wails/pkg/application/application.go
new file mode 100644
index 0000000..670ceb2
--- /dev/null
+++ b/stubs/wails/pkg/application/application.go
@@ -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()
+}
diff --git a/stubs/wails/pkg/events/events.go b/stubs/wails/pkg/events/events.go
new file mode 100644
index 0000000..3f3204d
--- /dev/null
+++ b/stubs/wails/pkg/events/events.go
@@ -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,
+}