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, +}