Compare commits
46 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
873eafe7b6 | ||
|
|
031c286fb9 | ||
|
|
6b3879fb9a | ||
|
|
d9491380f8 | ||
|
|
98b73fc14c | ||
|
|
274a81689c | ||
|
|
d3443d4be9 | ||
|
|
0204540b20 | ||
|
|
29dc0d9877 | ||
|
|
bca53679f1 | ||
|
|
dd9e8da619 | ||
|
|
b50149af5d | ||
|
|
a23e265cc6 | ||
|
|
3cf69533bf | ||
|
|
0423f3058d | ||
|
|
81503d0968 | ||
|
|
8db26398af | ||
|
|
f2eb9f03c4 | ||
|
|
fdff5435c2 | ||
|
|
cf8091e7e7 | ||
|
|
b8ddd2650b | ||
|
|
0d2ae6c299 | ||
|
|
cf284e9954 | ||
|
|
3d7998a9ca | ||
|
|
856bb89022 | ||
|
|
cad4e212c4 | ||
|
|
483c408497 | ||
|
|
4f03fc4c64 | ||
|
|
4f7236a8bb | ||
|
|
54d77d85cd | ||
|
|
4f4a4eb8e4 | ||
|
|
45fa6942f7 | ||
|
|
77e03060ac | ||
|
|
81b71ff50b | ||
|
|
61ddae80f4 | ||
|
|
c3361b7064 | ||
|
|
973217ae54 | ||
|
|
a07fa49c20 | ||
|
|
57fb567a68 | ||
|
|
a4c696ec01 | ||
|
|
573eb5216a | ||
|
|
a0cad39fbb | ||
|
|
3413b64f6c | ||
|
|
3c5c109c3a | ||
|
|
a1fbcdf6ed | ||
|
|
5653bfcc8d |
124 changed files with 8925 additions and 3277 deletions
|
|
@ -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.go` | `WindowOption` functional options pattern, `Window` type alias for `application.WebviewWindowOptions` |
|
||||||
| `window_state.go` | `WindowStateManager` — persists window position/size across restarts |
|
| `window_state.go` | `WindowStateManager` — persists window position/size across restarts |
|
||||||
| `layout.go` | `LayoutManager` — save/restore named window arrangements |
|
| `layout.go` | `LayoutManager` — save/restore named window arrangements |
|
||||||
| `events.go` | `WebSocketEventManager` — WebSocket pub/sub for window/theme/screen events |
|
| `events.go` | `WSEventManager` — WebSocket pub/sub for window/theme/screen events |
|
||||||
| `interfaces.go` | Abstract interfaces + Wails adapter implementations |
|
| `interfaces.go` | Abstract interfaces + Wails adapter implementations |
|
||||||
| `actions.go` | `ActionOpenWindow` IPC message type |
|
| `actions.go` | `ActionOpenWindow` IPC message type |
|
||||||
| `menu.go` | Application menu construction |
|
| `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`
|
- **Functional options**: `WindowOption` functions (`WindowName()`, `WindowTitle()`, `WindowWidth()`, etc.) configure `application.WebviewWindowOptions`
|
||||||
- **Type alias**: `Window = application.WebviewWindowOptions` — direct alias, not a wrapper
|
- **Type alias**: `Window = application.WebviewWindowOptions` — direct alias, not a wrapper
|
||||||
- **Event broadcasting**: `WebSocketEventManager` uses gorilla/websocket with a buffered channel (`eventBuffer`) and per-client subscription filtering (supports `"*"` wildcard)
|
- **Event broadcasting**: `WSEventManager` 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()`
|
- **Window lookup by name**: Most Service methods iterate `s.app.Window().GetAll()` and type-assert to `*application.WebviewWindow`, then match by `Name()`
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ Complete API reference for the Display service (`pkg/display`).
|
||||||
## Service Creation
|
## Service Creation
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func NewService() *Service
|
func NewService(c *core.Core) (any, error)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Window Management
|
## Window Management
|
||||||
|
|
@ -52,17 +52,17 @@ type WindowInfo struct {
|
||||||
Y int
|
Y int
|
||||||
Width int
|
Width int
|
||||||
Height int
|
Height int
|
||||||
Visible bool
|
IsVisible bool
|
||||||
Minimized bool
|
IsFocused bool
|
||||||
Maximized bool
|
IsMaximized bool
|
||||||
Focused bool
|
IsMinimized bool
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### ListWindowInfos
|
### ListWindowInfos
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func (s *Service) ListWindowInfos() []WindowInfo
|
func (s *Service) ListWindowInfos() []*WindowInfo
|
||||||
```
|
```
|
||||||
|
|
||||||
### Window Position & Size
|
### Window Position & Size
|
||||||
|
|
@ -346,7 +346,7 @@ type Theme struct {
|
||||||
## Events
|
## Events
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func (s *Service) GetWebSocketEventManager() *WebSocketEventManager
|
func (s *Service) GetEventManager() *EventManager
|
||||||
```
|
```
|
||||||
|
|
||||||
The WebSocketEventManager handles WebSocket connections for real-time events.
|
The EventManager handles WebSocket connections for real-time events.
|
||||||
|
|
|
||||||
|
|
@ -74,9 +74,10 @@ The help service can work standalone or integrated with Core:
|
||||||
|
|
||||||
### With Display Service
|
### With Display Service
|
||||||
|
|
||||||
When Display service is available, help opens through the display service's declarative window API:
|
When Display service is available, help opens through the IPC action system:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
|
// Automatically uses display.open_window action
|
||||||
helpService.Init(core, displayService)
|
helpService.Init(core, displayService)
|
||||||
helpService.Show()
|
helpService.Show()
|
||||||
```
|
```
|
||||||
|
|
@ -133,6 +134,19 @@ The help window opens with default settings:
|
||||||
| Width | 800px |
|
| Width | 800px |
|
||||||
| Height | 600px |
|
| Height | 600px |
|
||||||
|
|
||||||
## Display Integration
|
## IPC Action
|
||||||
|
|
||||||
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.
|
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
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ Response:
|
||||||
| `window_maximize` | Maximize window |
|
| `window_maximize` | Maximize window |
|
||||||
| `window_minimize` | Minimize window |
|
| `window_minimize` | Minimize window |
|
||||||
| `window_focus` | Bring window to front |
|
| `window_focus` | Bring window to front |
|
||||||
|
| `window_title_set` | Alias for `window_title` |
|
||||||
|
|
||||||
### WebView Interaction
|
### WebView Interaction
|
||||||
|
|
||||||
|
|
@ -84,6 +85,7 @@ Response:
|
||||||
| `webview_screenshot` | Capture page |
|
| `webview_screenshot` | Capture page |
|
||||||
| `webview_navigate` | Navigate to URL |
|
| `webview_navigate` | Navigate to URL |
|
||||||
| `webview_console` | Get console messages |
|
| `webview_console` | Get console messages |
|
||||||
|
| `webview_errors` | Get structured JavaScript errors |
|
||||||
|
|
||||||
### Screen Management
|
### Screen Management
|
||||||
|
|
||||||
|
|
@ -93,6 +95,8 @@ Response:
|
||||||
| `screen_primary` | Get primary screen |
|
| `screen_primary` | Get primary screen |
|
||||||
| `screen_at_point` | Get screen at coordinates |
|
| `screen_at_point` | Get screen at coordinates |
|
||||||
| `screen_work_areas` | Get usable screen space |
|
| `screen_work_areas` | Get usable screen space |
|
||||||
|
| `screen_work_area` | Alias for `screen_work_areas` |
|
||||||
|
| `screen_for_window` | Get the screen containing a window |
|
||||||
|
|
||||||
### Layout Management
|
### Layout Management
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
module github.com/wailsapp/wails/v3
|
module github.com/wailsapp/wails/v3
|
||||||
|
|
||||||
go 1.26.0
|
go 1.24
|
||||||
|
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
<!doctype html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8">
|
|
||||||
<title>Wails Assets Placeholder</title>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
1
docs/ref/wails-v3/src/application/assets/placeholder.txt
Normal file
1
docs/ref/wails-v3/src/application/assets/placeholder.txt
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
placeholder
|
||||||
14
go.mod
14
go.mod
|
|
@ -1,22 +1,22 @@
|
||||||
module forge.lthn.ai/core/gui
|
module dappco.re/go/core/gui
|
||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
forge.lthn.ai/core/config v0.1.8
|
dappco.re/go/core/config v0.1.8
|
||||||
forge.lthn.ai/core/go v0.3.3
|
dappco.re/go/core v0.3.3
|
||||||
forge.lthn.ai/core/go-io v0.1.7
|
dappco.re/go/core/webview 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/gorilla/websocket v1.5.3
|
||||||
github.com/modelcontextprotocol/go-sdk v1.4.1
|
github.com/modelcontextprotocol/go-sdk v1.4.1
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/wailsapp/wails/v3 v3.0.0-alpha.74
|
github.com/wailsapp/wails/v3 v3.0.0-alpha.74
|
||||||
)
|
)
|
||||||
|
|
||||||
replace github.com/wailsapp/wails/v3 => ./stubs/wails
|
replace github.com/wailsapp/wails/v3 => ./stubs/wails/v3
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
dappco.re/go/core/io v0.1.7 // indirect
|
||||||
|
dappco.re/go/core/log v0.0.4 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||||
|
|
|
||||||
30
go.sum
30
go.sum
|
|
@ -1,15 +1,32 @@
|
||||||
|
cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k=
|
||||||
|
forge.lthn.ai/Snider/Borg v0.3.1/go.mod h1:Z7DJD0yHXsxSyM7Mjl6/g4gH1NBsIz44Bf5AFlV76Wg=
|
||||||
forge.lthn.ai/core/config v0.1.8 h1:xP2hys7T94QGVF/OTh84/Zr5Dm/dL/0vzjht8zi+LOg=
|
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/config v0.1.8/go.mod h1:8epZrkwoCt+5ayrqdinOUU/+w6UoxOyv9ZrdgVOgYfQ=
|
||||||
forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0=
|
forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0=
|
||||||
forge.lthn.ai/core/go v0.3.3/go.mod h1:Cp4ac25pghvO2iqOu59t1GyngTKVOzKB5/VPdhRi9CQ=
|
forge.lthn.ai/core/go v0.3.3/go.mod h1:Cp4ac25pghvO2iqOu59t1GyngTKVOzKB5/VPdhRi9CQ=
|
||||||
|
forge.lthn.ai/core/go-crypt v0.1.6/go.mod h1:4VZAGqxlbadhSB66sJkdj54/HSJ+bSxVgwWK5kMMYDo=
|
||||||
forge.lthn.ai/core/go-io v0.1.7 h1:Tdb6sqh+zz1lsGJaNX9RFWM6MJ/RhSAyxfulLXrJsbk=
|
forge.lthn.ai/core/go-io v0.1.7 h1:Tdb6sqh+zz1lsGJaNX9RFWM6MJ/RhSAyxfulLXrJsbk=
|
||||||
forge.lthn.ai/core/go-io v0.1.7/go.mod h1:8lRLFk4Dnp5cR/Cyzh9WclD5566TbpdRgwcH7UZLWn4=
|
forge.lthn.ai/core/go-io v0.1.7/go.mod h1:8lRLFk4Dnp5cR/Cyzh9WclD5566TbpdRgwcH7UZLWn4=
|
||||||
forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0=
|
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-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 h1:9+aEHeAvNcPX8Zwr+UGu0/T+menRm5T1YOmqZ9dawDc=
|
||||||
forge.lthn.ai/core/go-webview v0.1.7/go.mod h1:5n1tECD1wBV/uFZRY9ZjfPFO5TYZrlaR3mQFwvO2nek=
|
forge.lthn.ai/core/go-webview v0.1.7/go.mod h1:5n1tECD1wBV/uFZRY9ZjfPFO5TYZrlaR3mQFwvO2nek=
|
||||||
|
github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo=
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21/go.mod h1:UUxgWxofmOdAMuqEsSppbDtGKLfR04HGsD0HXzvhI1k=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12/go.mod h1:v2pNpJbRNl4vEUWEh5ytQok0zACAKfdmKS51Hotc3pQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20/go.mod h1:4TLZCmVJDM3FOu5P5TJP0zOlu9zWgDWU7aUxWbr+rcw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.97.1/go.mod h1:qXVal5H0ChqXP63t6jze5LmFalc7+ZE7wOdLtZ0LCP0=
|
||||||
|
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||||
|
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
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/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 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
|
|
@ -22,18 +39,22 @@ 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/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 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
|
||||||
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
|
||||||
|
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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
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 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc=
|
||||||
github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s=
|
github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s=
|
||||||
|
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
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/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
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/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
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/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 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||||
|
|
@ -42,6 +63,7 @@ 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/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 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
|
||||||
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
|
||||||
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
|
|
@ -50,6 +72,7 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
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 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
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/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 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
|
|
@ -58,8 +81,11 @@ github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zI
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
|
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 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||||
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
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/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
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/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
|
|
@ -71,3 +97,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
|
// pkg/browser/register.go
|
||||||
package browser
|
package browser
|
||||||
|
|
||||||
import "forge.lthn.ai/core/go/pkg/core"
|
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) {
|
func Register(p Platform) func(*core.Core) (any, error) {
|
||||||
return func(c *core.Core) (any, error) {
|
return func(c *core.Core) (any, error) {
|
||||||
return &Service{
|
return &Service{
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// pkg/browser/service.go
|
||||||
package browser
|
package browser
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -6,18 +7,23 @@ import (
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
"forge.lthn.ai/core/go/pkg/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Options holds configuration for the browser service.
|
||||||
type Options struct{}
|
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 {
|
type Service struct {
|
||||||
*core.ServiceRuntime[Options]
|
*core.ServiceRuntime[Options]
|
||||||
platform Platform
|
platform Platform
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OnStartup registers IPC handlers.
|
||||||
func (s *Service) OnStartup(ctx context.Context) error {
|
func (s *Service) OnStartup(ctx context.Context) error {
|
||||||
s.Core().RegisterTask(s.handleTask)
|
s.Core().RegisterTask(s.handleTask)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleIPCEvents is auto-discovered and registered by core.WithService.
|
||||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,3 +9,16 @@ type TaskSetText struct{ Text string }
|
||||||
|
|
||||||
// TaskClear clears the clipboard. Result: bool (success)
|
// TaskClear clears the clipboard. Result: bool (success)
|
||||||
type TaskClear struct{}
|
type TaskClear struct{}
|
||||||
|
|
||||||
|
// QueryImage reads an image from the clipboard. Result: ClipboardImageContent
|
||||||
|
type QueryImage struct{}
|
||||||
|
|
||||||
|
// TaskSetImage writes image bytes to the clipboard. Result: bool (success)
|
||||||
|
type TaskSetImage struct{ Data []byte }
|
||||||
|
|
||||||
|
// ClipboardImageContent contains clipboard image data encoded for transport.
|
||||||
|
type ClipboardImageContent struct {
|
||||||
|
Base64 string `json:"base64"`
|
||||||
|
MimeType string `json:"mimeType"`
|
||||||
|
HasContent bool `json:"hasContent"`
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
// pkg/clipboard/platform.go
|
// pkg/clipboard/platform.go
|
||||||
package clipboard
|
package clipboard
|
||||||
|
|
||||||
|
import "encoding/base64"
|
||||||
|
|
||||||
// Platform abstracts the system clipboard backend.
|
// Platform abstracts the system clipboard backend.
|
||||||
type Platform interface {
|
type Platform interface {
|
||||||
Text() (string, bool)
|
Text() (string, bool)
|
||||||
|
|
@ -12,3 +14,22 @@ type ClipboardContent struct {
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
HasContent bool `json:"hasContent"`
|
HasContent bool `json:"hasContent"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// imageReader is an optional clipboard capability for image reads.
|
||||||
|
type imageReader interface {
|
||||||
|
Image() ([]byte, bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageWriter is an optional clipboard capability for image writes.
|
||||||
|
type imageWriter interface {
|
||||||
|
SetImage(data []byte) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeImageContent converts raw bytes to transport-safe clipboard image content.
|
||||||
|
func encodeImageContent(data []byte) ClipboardImageContent {
|
||||||
|
return ClipboardImageContent{
|
||||||
|
Base64: base64.StdEncoding.EncodeToString(data),
|
||||||
|
MimeType: "image/png",
|
||||||
|
HasContent: len(data) > 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,16 +7,19 @@ import (
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
"forge.lthn.ai/core/go/pkg/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Options holds configuration for the clipboard service.
|
// Options configures the clipboard service.
|
||||||
|
// Use: core.WithService(clipboard.Register(platform))
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
|
|
||||||
// Service is a core.Service managing clipboard operations via IPC.
|
// Service manages clipboard operations via Core queries and tasks.
|
||||||
|
// Use: svc := &clipboard.Service{}
|
||||||
type Service struct {
|
type Service struct {
|
||||||
*core.ServiceRuntime[Options]
|
*core.ServiceRuntime[Options]
|
||||||
platform Platform
|
platform Platform
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register creates a factory closure that captures the Platform adapter.
|
// Register creates a Core service factory for the clipboard backend.
|
||||||
|
// Use: core.New(core.WithService(clipboard.Register(platform)))
|
||||||
func Register(p Platform) func(*core.Core) (any, error) {
|
func Register(p Platform) func(*core.Core) (any, error) {
|
||||||
return func(c *core.Core) (any, error) {
|
return func(c *core.Core) (any, error) {
|
||||||
return &Service{
|
return &Service{
|
||||||
|
|
@ -26,14 +29,15 @@ func Register(p Platform) func(*core.Core) (any, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnStartup registers IPC handlers.
|
// OnStartup registers clipboard handlers with Core.
|
||||||
|
// Use: _ = svc.OnStartup(context.Background())
|
||||||
func (s *Service) OnStartup(ctx context.Context) error {
|
func (s *Service) OnStartup(ctx context.Context) error {
|
||||||
s.Core().RegisterQuery(s.handleQuery)
|
s.Core().RegisterQuery(s.handleQuery)
|
||||||
s.Core().RegisterTask(s.handleTask)
|
s.Core().RegisterTask(s.handleTask)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleIPCEvents is auto-discovered by core.WithService.
|
// HandleIPCEvents satisfies Core's IPC hook.
|
||||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -43,6 +47,12 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
||||||
case QueryText:
|
case QueryText:
|
||||||
text, ok := s.platform.Text()
|
text, ok := s.platform.Text()
|
||||||
return ClipboardContent{Text: text, HasContent: ok && text != ""}, true, nil
|
return ClipboardContent{Text: text, HasContent: ok && text != ""}, true, nil
|
||||||
|
case QueryImage:
|
||||||
|
if reader, ok := s.platform.(imageReader); ok {
|
||||||
|
data, _ := reader.Image()
|
||||||
|
return encodeImageContent(data), true, nil
|
||||||
|
}
|
||||||
|
return ClipboardImageContent{MimeType: "image/png"}, true, nil
|
||||||
default:
|
default:
|
||||||
return nil, false, nil
|
return nil, false, nil
|
||||||
}
|
}
|
||||||
|
|
@ -53,7 +63,17 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
||||||
case TaskSetText:
|
case TaskSetText:
|
||||||
return s.platform.SetText(t.Text), true, nil
|
return s.platform.SetText(t.Text), true, nil
|
||||||
case TaskClear:
|
case TaskClear:
|
||||||
return s.platform.SetText(""), true, nil
|
_ = s.platform.SetText("")
|
||||||
|
if writer, ok := s.platform.(imageWriter); ok {
|
||||||
|
// Best-effort clear for image-aware clipboard backends.
|
||||||
|
_ = writer.SetImage(nil)
|
||||||
|
}
|
||||||
|
return true, true, nil
|
||||||
|
case TaskSetImage:
|
||||||
|
if writer, ok := s.platform.(imageWriter); ok {
|
||||||
|
return writer.SetImage(t.Data), true, nil
|
||||||
|
}
|
||||||
|
return false, true, core.E("clipboard.handleTask", "clipboard image write not supported", nil)
|
||||||
default:
|
default:
|
||||||
return nil, false, nil
|
return nil, false, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,8 @@ import (
|
||||||
type mockPlatform struct {
|
type mockPlatform struct {
|
||||||
text string
|
text string
|
||||||
ok bool
|
ok bool
|
||||||
|
img []byte
|
||||||
|
imgOk bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockPlatform) Text() (string, bool) { return m.text, m.ok }
|
func (m *mockPlatform) Text() (string, bool) { return m.text, m.ok }
|
||||||
|
|
@ -21,6 +23,12 @@ func (m *mockPlatform) SetText(text string) bool {
|
||||||
m.ok = text != ""
|
m.ok = text != ""
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
func (m *mockPlatform) Image() ([]byte, bool) { return m.img, m.imgOk }
|
||||||
|
func (m *mockPlatform) SetImage(data []byte) bool {
|
||||||
|
m.img = data
|
||||||
|
m.imgOk = len(data) > 0
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func newTestService(t *testing.T) (*Service, *core.Core) {
|
func newTestService(t *testing.T) (*Service, *core.Core) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
@ -79,3 +87,34 @@ func TestTaskClear_Good(t *testing.T) {
|
||||||
assert.Equal(t, "", r.(ClipboardContent).Text)
|
assert.Equal(t, "", r.(ClipboardContent).Text)
|
||||||
assert.False(t, r.(ClipboardContent).HasContent)
|
assert.False(t, r.(ClipboardContent).HasContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestQueryImage_Good(t *testing.T) {
|
||||||
|
mock := &mockPlatform{img: []byte{1, 2, 3}, imgOk: true}
|
||||||
|
c, err := core.New(
|
||||||
|
core.WithService(Register(mock)),
|
||||||
|
core.WithServiceLock(),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
||||||
|
|
||||||
|
result, handled, err := c.QUERY(QueryImage{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
image := result.(ClipboardImageContent)
|
||||||
|
assert.True(t, image.HasContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskSetImage_Good(t *testing.T) {
|
||||||
|
mock := &mockPlatform{}
|
||||||
|
c, err := core.New(
|
||||||
|
core.WithService(Register(mock)),
|
||||||
|
core.WithServiceLock(),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
||||||
|
|
||||||
|
_, handled, err := c.PERFORM(TaskSetImage{Data: []byte{9, 8, 7}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
assert.True(t, mock.imgOk)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,42 @@
|
||||||
|
// pkg/contextmenu/messages.go
|
||||||
package contextmenu
|
package contextmenu
|
||||||
|
|
||||||
import "errors"
|
import "errors"
|
||||||
|
|
||||||
var ErrorMenuNotFound = errors.New("contextmenu: menu not found")
|
// 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")
|
||||||
|
|
||||||
// --- Queries ---
|
// --- Queries ---
|
||||||
|
|
||||||
|
// QueryGet returns a single context menu by name. Result: *ContextMenuDef (nil if not found)
|
||||||
type QueryGet struct {
|
type QueryGet struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QueryList returns all registered context menus. Result: map[string]ContextMenuDef
|
||||||
type QueryList struct{}
|
type QueryList struct{}
|
||||||
|
|
||||||
// --- Tasks ---
|
// --- 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 {
|
type TaskAdd struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Menu ContextMenuDef `json:"menu"`
|
Menu ContextMenuDef `json:"menu"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TaskRemove unregisters a context menu. Result: nil
|
||||||
|
// Returns ErrMenuNotFound if the menu does not exist.
|
||||||
type TaskRemove struct {
|
type TaskRemove struct {
|
||||||
Name string `json:"name"`
|
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 {
|
type ActionItemClicked struct {
|
||||||
MenuName string `json:"menuName"`
|
MenuName string `json:"menuName"`
|
||||||
ActionID string `json:"actionId"`
|
ActionID string `json:"actionId"`
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
|
// pkg/contextmenu/register.go
|
||||||
package contextmenu
|
package contextmenu
|
||||||
|
|
||||||
import "forge.lthn.ai/core/go/pkg/core"
|
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) {
|
func Register(p Platform) func(*core.Core) (any, error) {
|
||||||
return func(c *core.Core) (any, error) {
|
return func(c *core.Core) (any, error) {
|
||||||
return &Service{
|
return &Service{
|
||||||
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
||||||
platform: p,
|
platform: p,
|
||||||
registeredMenus: make(map[string]ContextMenuDef),
|
menus: make(map[string]ContextMenuDef),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,25 +3,31 @@ package contextmenu
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
"forge.lthn.ai/core/go/pkg/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Options holds configuration for the context menu service.
|
||||||
type Options struct{}
|
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 {
|
type Service struct {
|
||||||
*core.ServiceRuntime[Options]
|
*core.ServiceRuntime[Options]
|
||||||
platform Platform
|
platform Platform
|
||||||
registeredMenus map[string]ContextMenuDef
|
menus map[string]ContextMenuDef
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OnStartup registers IPC handlers.
|
||||||
func (s *Service) OnStartup(ctx context.Context) error {
|
func (s *Service) OnStartup(ctx context.Context) error {
|
||||||
s.Core().RegisterQuery(s.handleQuery)
|
s.Core().RegisterQuery(s.handleQuery)
|
||||||
s.Core().RegisterTask(s.handleTask)
|
s.Core().RegisterTask(s.handleTask)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleIPCEvents is auto-discovered and registered by core.WithService.
|
||||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -39,17 +45,19 @@ 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 {
|
func (s *Service) queryGet(q QueryGet) *ContextMenuDef {
|
||||||
menu, ok := s.registeredMenus[q.Name]
|
menu, ok := s.menus[q.Name]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &menu
|
return &menu
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// queryList returns a copy of all registered menus.
|
||||||
func (s *Service) queryList() map[string]ContextMenuDef {
|
func (s *Service) queryList() map[string]ContextMenuDef {
|
||||||
result := make(map[string]ContextMenuDef, len(s.registeredMenus))
|
result := make(map[string]ContextMenuDef, len(s.menus))
|
||||||
for k, v := range s.registeredMenus {
|
for k, v := range s.menus {
|
||||||
result[k] = v
|
result[k] = v
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|
@ -70,9 +78,9 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
||||||
|
|
||||||
func (s *Service) taskAdd(t TaskAdd) error {
|
func (s *Service) taskAdd(t TaskAdd) error {
|
||||||
// If menu already exists, remove it first (replace semantics)
|
// If menu already exists, remove it first (replace semantics)
|
||||||
if _, exists := s.registeredMenus[t.Name]; exists {
|
if _, exists := s.menus[t.Name]; exists {
|
||||||
_ = s.platform.Remove(t.Name)
|
_ = s.platform.Remove(t.Name)
|
||||||
delete(s.registeredMenus, t.Name)
|
delete(s.menus, t.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register on platform with a callback that broadcasts ActionItemClicked
|
// Register on platform with a callback that broadcasts ActionItemClicked
|
||||||
|
|
@ -84,23 +92,23 @@ func (s *Service) taskAdd(t TaskAdd) error {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return coreerr.E("contextmenu.taskAdd", "platform add failed", err)
|
return fmt.Errorf("contextmenu: platform add failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.registeredMenus[t.Name] = t.Menu
|
s.menus[t.Name] = t.Menu
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) taskRemove(t TaskRemove) error {
|
func (s *Service) taskRemove(t TaskRemove) error {
|
||||||
if _, exists := s.registeredMenus[t.Name]; !exists {
|
if _, exists := s.menus[t.Name]; !exists {
|
||||||
return ErrorMenuNotFound
|
return ErrMenuNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
err := s.platform.Remove(t.Name)
|
err := s.platform.Remove(t.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return coreerr.E("contextmenu.taskRemove", "platform remove failed", err)
|
return fmt.Errorf("contextmenu: platform remove failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(s.registeredMenus, t.Name)
|
delete(s.menus, t.Name)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -171,7 +171,7 @@ func TestTaskRemove_Bad_NotFound(t *testing.T) {
|
||||||
|
|
||||||
_, handled, err := c.PERFORM(TaskRemove{Name: "nonexistent"})
|
_, handled, err := c.PERFORM(TaskRemove{Name: "nonexistent"})
|
||||||
assert.True(t, handled)
|
assert.True(t, handled)
|
||||||
assert.ErrorIs(t, err, ErrorMenuNotFound)
|
assert.ErrorIs(t, err, ErrMenuNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryGet_Good(t *testing.T) {
|
func TestQueryGet_Good(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,14 @@
|
||||||
|
// pkg/dialog/messages.go
|
||||||
package dialog
|
package dialog
|
||||||
|
|
||||||
type TaskOpenFile struct{ Options OpenFileOptions }
|
// TaskOpenFile shows an open file dialog. Result: []string (paths)
|
||||||
|
type TaskOpenFile struct{ Opts OpenFileOptions }
|
||||||
|
|
||||||
type TaskSaveFile struct{ Options SaveFileOptions }
|
// TaskSaveFile shows a save file dialog. Result: string (path)
|
||||||
|
type TaskSaveFile struct{ Opts SaveFileOptions }
|
||||||
|
|
||||||
type TaskOpenDirectory struct{ Options OpenDirectoryOptions }
|
// TaskOpenDirectory shows a directory picker. Result: string (path)
|
||||||
|
type TaskOpenDirectory struct{ Opts OpenDirectoryOptions }
|
||||||
|
|
||||||
type TaskMessageDialog struct{ Options MessageDialogOptions }
|
// TaskMessageDialog shows a message dialog. Result: string (button clicked)
|
||||||
|
type TaskMessageDialog struct{ Opts MessageDialogOptions }
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,10 @@ package dialog
|
||||||
|
|
||||||
// Platform abstracts the native dialog backend.
|
// Platform abstracts the native dialog backend.
|
||||||
type Platform interface {
|
type Platform interface {
|
||||||
OpenFile(options OpenFileOptions) ([]string, error)
|
OpenFile(opts OpenFileOptions) ([]string, error)
|
||||||
SaveFile(options SaveFileOptions) (string, error)
|
SaveFile(opts SaveFileOptions) (string, error)
|
||||||
OpenDirectory(options OpenDirectoryOptions) (string, error)
|
OpenDirectory(opts OpenDirectoryOptions) (string, error)
|
||||||
MessageDialog(options MessageDialogOptions) (string, error)
|
MessageDialog(opts MessageDialogOptions) (string, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// DialogType represents the type of message dialog.
|
// DialogType represents the type of message dialog.
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,16 @@ import (
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
"forge.lthn.ai/core/go/pkg/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Options holds configuration for the dialog service.
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
|
|
||||||
|
// Service is a core.Service managing native dialogs via IPC.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
*core.ServiceRuntime[Options]
|
*core.ServiceRuntime[Options]
|
||||||
platform Platform
|
platform Platform
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register creates a factory closure that captures the Platform adapter.
|
||||||
func Register(p Platform) func(*core.Core) (any, error) {
|
func Register(p Platform) func(*core.Core) (any, error) {
|
||||||
return func(c *core.Core) (any, error) {
|
return func(c *core.Core) (any, error) {
|
||||||
return &Service{
|
return &Service{
|
||||||
|
|
@ -23,11 +26,13 @@ func Register(p Platform) func(*core.Core) (any, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OnStartup registers IPC handlers.
|
||||||
func (s *Service) OnStartup(ctx context.Context) error {
|
func (s *Service) OnStartup(ctx context.Context) error {
|
||||||
s.Core().RegisterTask(s.handleTask)
|
s.Core().RegisterTask(s.handleTask)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleIPCEvents is auto-discovered by core.WithService.
|
||||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -35,16 +40,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) {
|
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
||||||
switch t := t.(type) {
|
switch t := t.(type) {
|
||||||
case TaskOpenFile:
|
case TaskOpenFile:
|
||||||
paths, err := s.platform.OpenFile(t.Options)
|
paths, err := s.platform.OpenFile(t.Opts)
|
||||||
return paths, true, err
|
return paths, true, err
|
||||||
case TaskSaveFile:
|
case TaskSaveFile:
|
||||||
path, err := s.platform.SaveFile(t.Options)
|
path, err := s.platform.SaveFile(t.Opts)
|
||||||
return path, true, err
|
return path, true, err
|
||||||
case TaskOpenDirectory:
|
case TaskOpenDirectory:
|
||||||
path, err := s.platform.OpenDirectory(t.Options)
|
path, err := s.platform.OpenDirectory(t.Opts)
|
||||||
return path, true, err
|
return path, true, err
|
||||||
case TaskMessageDialog:
|
case TaskMessageDialog:
|
||||||
button, err := s.platform.MessageDialog(t.Options)
|
button, err := s.platform.MessageDialog(t.Opts)
|
||||||
return button, true, err
|
return button, true, err
|
||||||
default:
|
default:
|
||||||
return nil, false, nil
|
return nil, false, nil
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ func TestTaskOpenFile_Good(t *testing.T) {
|
||||||
mock.openFilePaths = []string{"/a.txt", "/b.txt"}
|
mock.openFilePaths = []string{"/a.txt", "/b.txt"}
|
||||||
|
|
||||||
result, handled, err := c.PERFORM(TaskOpenFile{
|
result, handled, err := c.PERFORM(TaskOpenFile{
|
||||||
Options: OpenFileOptions{Title: "Pick", AllowMultiple: true},
|
Opts: OpenFileOptions{Title: "Pick", AllowMultiple: true},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, handled)
|
assert.True(t, handled)
|
||||||
|
|
@ -83,7 +83,7 @@ func TestTaskOpenFile_Good(t *testing.T) {
|
||||||
func TestTaskSaveFile_Good(t *testing.T) {
|
func TestTaskSaveFile_Good(t *testing.T) {
|
||||||
_, c := newTestService(t)
|
_, c := newTestService(t)
|
||||||
result, handled, err := c.PERFORM(TaskSaveFile{
|
result, handled, err := c.PERFORM(TaskSaveFile{
|
||||||
Options: SaveFileOptions{Filename: "out.txt"},
|
Opts: SaveFileOptions{Filename: "out.txt"},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, handled)
|
assert.True(t, handled)
|
||||||
|
|
@ -93,7 +93,7 @@ func TestTaskSaveFile_Good(t *testing.T) {
|
||||||
func TestTaskOpenDirectory_Good(t *testing.T) {
|
func TestTaskOpenDirectory_Good(t *testing.T) {
|
||||||
_, c := newTestService(t)
|
_, c := newTestService(t)
|
||||||
result, handled, err := c.PERFORM(TaskOpenDirectory{
|
result, handled, err := c.PERFORM(TaskOpenDirectory{
|
||||||
Options: OpenDirectoryOptions{Title: "Pick Dir"},
|
Opts: OpenDirectoryOptions{Title: "Pick Dir"},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, handled)
|
assert.True(t, handled)
|
||||||
|
|
@ -105,7 +105,7 @@ func TestTaskMessageDialog_Good(t *testing.T) {
|
||||||
mock.messageButton = "Yes"
|
mock.messageButton = "Yes"
|
||||||
|
|
||||||
result, handled, err := c.PERFORM(TaskMessageDialog{
|
result, handled, err := c.PERFORM(TaskMessageDialog{
|
||||||
Options: MessageDialogOptions{
|
Opts: MessageDialogOptions{
|
||||||
Type: DialogQuestion, Title: "Confirm",
|
Type: DialogQuestion, Title: "Confirm",
|
||||||
Message: "Sure?", Buttons: []string{"Yes", "No"},
|
Message: "Sure?", Buttons: []string{"Yes", "No"},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ This document tracks the implementation of display server features that enable A
|
||||||
- [x] `window_title_get` - Get current window title (returns window name)
|
- [x] `window_title_get` - Get current window title (returns window name)
|
||||||
- [x] `window_always_on_top` - Pin window above others
|
- [x] `window_always_on_top` - Pin window above others
|
||||||
- [x] `window_background_colour` - Set window background color with alpha (transparency)
|
- [x] `window_background_colour` - Set window background color with alpha (transparency)
|
||||||
|
- [x] `window_opacity` - Set window opacity
|
||||||
- [x] `window_fullscreen` - Enter/exit fullscreen mode
|
- [x] `window_fullscreen` - Enter/exit fullscreen mode
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -59,13 +60,13 @@ This document tracks the implementation of display server features that enable A
|
||||||
### Smart Layout
|
### Smart Layout
|
||||||
- [x] `layout_tile` - Auto-tile windows (left/right/top/bottom/quadrants/grid)
|
- [x] `layout_tile` - Auto-tile windows (left/right/top/bottom/quadrants/grid)
|
||||||
- [x] `layout_stack` - Stack windows in cascade pattern
|
- [x] `layout_stack` - Stack windows in cascade pattern
|
||||||
- [ ] `layout_beside_editor` - Position window beside detected IDE window
|
- [x] `layout_beside_editor` - Position window beside detected IDE window
|
||||||
- [ ] `layout_suggest` - Given screen dimensions, suggest optimal arrangement
|
- [x] `layout_suggest` - Given screen dimensions, suggest optimal arrangement
|
||||||
- [x] `layout_snap` - Snap window to screen edge/corner/center
|
- [x] `layout_snap` - Snap window to screen edge/corner/center
|
||||||
|
|
||||||
### AI-Optimized Layout
|
### AI-Optimized Layout
|
||||||
- [ ] `screen_find_space` - Find empty screen space for new window
|
- [x] `screen_find_space` - Find empty screen space for new window
|
||||||
- [ ] `window_arrange_pair` - Put two windows side-by-side optimally
|
- [x] `window_arrange_pair` - Put two windows side-by-side optimally
|
||||||
- [x] `layout_workflow` - Preset layouts: "coding", "debugging", "presenting", "side-by-side"
|
- [x] `layout_workflow` - Preset layouts: "coding", "debugging", "presenting", "side-by-side"
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -114,8 +115,8 @@ This document tracks the implementation of display server features that enable A
|
||||||
- [x] `webview_resources` - List loaded resources (scripts, styles, images)
|
- [x] `webview_resources` - List loaded resources (scripts, styles, images)
|
||||||
|
|
||||||
### DevTools
|
### DevTools
|
||||||
- [ ] `webview_devtools_open` - Open DevTools for window
|
- [x] `webview_devtools_open` - Open DevTools for window
|
||||||
- [ ] `webview_devtools_close` - Close DevTools
|
- [x] `webview_devtools_close` - Close DevTools
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -124,8 +125,8 @@ This document tracks the implementation of display server features that enable A
|
||||||
### Clipboard
|
### Clipboard
|
||||||
- [x] `clipboard_read` - Read clipboard text content
|
- [x] `clipboard_read` - Read clipboard text content
|
||||||
- [x] `clipboard_write` - Write text to clipboard
|
- [x] `clipboard_write` - Write text to clipboard
|
||||||
- [ ] `clipboard_read_image` - Read image from clipboard
|
- [x] `clipboard_read_image` - Read image from clipboard
|
||||||
- [ ] `clipboard_write_image` - Write image to clipboard
|
- [x] `clipboard_write_image` - Write image to clipboard
|
||||||
- [x] `clipboard_has` - Check clipboard content type
|
- [x] `clipboard_has` - Check clipboard content type
|
||||||
- [x] `clipboard_clear` - Clear clipboard contents
|
- [x] `clipboard_clear` - Clear clipboard contents
|
||||||
|
|
||||||
|
|
@ -133,8 +134,8 @@ This document tracks the implementation of display server features that enable A
|
||||||
- [x] `notification_show` - Show native system notification (macOS/Windows/Linux)
|
- [x] `notification_show` - Show native system notification (macOS/Windows/Linux)
|
||||||
- [x] `notification_permission_request` - Request notification permission
|
- [x] `notification_permission_request` - Request notification permission
|
||||||
- [x] `notification_permission_check` - Check notification authorization status
|
- [x] `notification_permission_check` - Check notification authorization status
|
||||||
- [ ] `notification_clear` - Clear notifications
|
- [x] `notification_clear` - Clear notifications
|
||||||
- [ ] `notification_with_actions` - Interactive notifications with buttons
|
- [x] `notification_with_actions` - Interactive notifications with buttons
|
||||||
|
|
||||||
### Dialogs
|
### Dialogs
|
||||||
- [x] `dialog_open_file` - Show file open dialog
|
- [x] `dialog_open_file` - Show file open dialog
|
||||||
|
|
@ -142,11 +143,11 @@ This document tracks the implementation of display server features that enable A
|
||||||
- [x] `dialog_open_directory` - Show directory picker
|
- [x] `dialog_open_directory` - Show directory picker
|
||||||
- [x] `dialog_message` - Show message dialog (info/warning/error) (via notification_show)
|
- [x] `dialog_message` - Show message dialog (info/warning/error) (via notification_show)
|
||||||
- [x] `dialog_confirm` - Show confirmation dialog
|
- [x] `dialog_confirm` - Show confirmation dialog
|
||||||
- [~] `dialog_prompt` - Show input prompt dialog (not supported natively in Wails v3)
|
- [x] `dialog_prompt` - Show input prompt dialog with a webview fallback when native support is unavailable
|
||||||
|
|
||||||
### Theme & Appearance
|
### Theme & Appearance
|
||||||
- [x] `theme_get` - Get current theme (dark/light)
|
- [x] `theme_get` - Get current theme (dark/light)
|
||||||
- [ ] `theme_set` - Set application theme
|
- [x] `theme_set` - Set application theme
|
||||||
- [x] `theme_system` - Get system theme preference
|
- [x] `theme_system` - Get system theme preference
|
||||||
- [x] `theme_on_change` - Subscribe to theme changes (via WebSocket events)
|
- [x] `theme_on_change` - Subscribe to theme changes (via WebSocket events)
|
||||||
|
|
||||||
|
|
@ -156,6 +157,7 @@ This document tracks the implementation of display server features that enable A
|
||||||
|
|
||||||
### Focus Management
|
### Focus Management
|
||||||
- [x] `window_focused` - Get currently focused window
|
- [x] `window_focused` - Get currently focused window
|
||||||
|
- [x] `focus_set` - Set focus to specific window (alias for window_focus)
|
||||||
|
|
||||||
### Event Subscriptions (WebSocket)
|
### Event Subscriptions (WebSocket)
|
||||||
- [x] `event_subscribe` - Subscribe to events (via WebSocket /events endpoint)
|
- [x] `event_subscribe` - Subscribe to events (via WebSocket /events endpoint)
|
||||||
|
|
@ -172,7 +174,7 @@ This document tracks the implementation of display server features that enable A
|
||||||
- [x] `tray_set_label` - Set tray label text
|
- [x] `tray_set_label` - Set tray label text
|
||||||
- [x] `tray_set_menu` - Set tray menu items (with nested submenus)
|
- [x] `tray_set_menu` - Set tray menu items (with nested submenus)
|
||||||
- [x] `tray_info` - Get tray status info
|
- [x] `tray_info` - Get tray status info
|
||||||
- [ ] `tray_show_message` - Show tray balloon notification
|
- [x] `tray_show_message` - Show tray balloon notification
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -214,7 +216,7 @@ This document tracks the implementation of display server features that enable A
|
||||||
- [x] WebSocket event subscriptions (/events endpoint)
|
- [x] WebSocket event subscriptions (/events endpoint)
|
||||||
- [x] Real-time window tracking (focus, blur, move, resize, close, create)
|
- [x] Real-time window tracking (focus, blur, move, resize, close, create)
|
||||||
- [x] Theme change events
|
- [x] Theme change events
|
||||||
- [x] screen_get, screen_primary, screen_at_point, screen_for_window
|
- [x] focus_set, screen_get, screen_primary, screen_at_point, screen_for_window
|
||||||
|
|
||||||
### Phase 7 - Advanced Features (DONE)
|
### Phase 7 - Advanced Features (DONE)
|
||||||
- [x] `window_background_colour` - Window transparency via RGBA alpha
|
- [x] `window_background_colour` - Window transparency via RGBA alpha
|
||||||
|
|
@ -234,7 +236,6 @@ This document tracks the implementation of display server features that enable A
|
||||||
- [x] `tray_info` - Get tray status
|
- [x] `tray_info` - Get tray status
|
||||||
|
|
||||||
### Phase 8 - Remaining Features (Future)
|
### Phase 8 - Remaining Features (Future)
|
||||||
- [ ] window_opacity (true opacity if Wails adds support)
|
|
||||||
- [ ] layout_beside_editor, layout_suggest
|
- [ ] layout_beside_editor, layout_suggest
|
||||||
- [ ] webview_devtools_open, webview_devtools_close
|
- [ ] webview_devtools_open, webview_devtools_close
|
||||||
- [ ] clipboard_read_image, clipboard_write_image
|
- [ ] clipboard_read_image, clipboard_write_image
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,43 @@
|
||||||
# Display
|
# Display
|
||||||
|
|
||||||
`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.
|
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.
|
||||||
|
|
||||||
## Working Locally
|
## Getting Started
|
||||||
|
|
||||||
1. Run the backend tests:
|
1. **Clone the repository:**
|
||||||
```bash
|
```bash
|
||||||
go test ./pkg/display/...
|
git clone https://github.com/Snider/display.git
|
||||||
```
|
```
|
||||||
2. Run the full workspace tests when you touch IPC contracts:
|
|
||||||
```bash
|
2. **Install the dependencies:**
|
||||||
go test ./...
|
|
||||||
```
|
|
||||||
3. Build the Angular frontend:
|
|
||||||
```bash
|
```bash
|
||||||
|
cd display
|
||||||
|
go mod tidy
|
||||||
cd ui
|
cd ui
|
||||||
npm install
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Run the development server:**
|
||||||
|
```bash
|
||||||
|
go run ./cmd/demo-cli serve
|
||||||
|
```
|
||||||
|
This will start the Go backend and serve the Angular custom element.
|
||||||
|
|
||||||
|
## Building the Custom Element
|
||||||
|
|
||||||
|
To build the Angular custom element, run the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ui
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Declarative Windows
|
This will create a single JavaScript file in the `dist` directory that you can use in any HTML page.
|
||||||
|
|
||||||
Windows are created from a `window.Window` spec instead of a fluent option chain.
|
## Contributing
|
||||||
|
|
||||||
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`.
|
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||||
Use `SetWindowBounds("editor", 100, 200, 1280, 720)` when you need to move and resize a window in one step.
|
|
||||||
|
|
||||||
The same spec shape is used by layout restore, tiling, snapping, and workflow presets.
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the EUPL-1.2 License - see the [LICENSE](LICENSE) file for details.
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -1,6 +1,6 @@
|
||||||
# Backend Documentation
|
# Backend Documentation
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## Core Types
|
## Core Types
|
||||||
|
|
||||||
|
|
@ -8,56 +8,47 @@ The backend is written in Go and uses the `forge.lthn.ai/core/gui/pkg/display` p
|
||||||
The `Service` struct is the main entry point for the display logic.
|
The `Service` struct is the main entry point for the display logic.
|
||||||
|
|
||||||
- **Initialization:**
|
- **Initialization:**
|
||||||
- `NewService() *Service`: Creates a new instance of the service.
|
- `New() (*Service, error)`: 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.
|
- `Startup(ctx context.Context) error`: Initializes the Wails application, builds the menu, sets up the system tray, and opens the main window.
|
||||||
- `OnStartup(ctx context.Context) error`: Loads config and registers IPC handlers.
|
|
||||||
|
|
||||||
- **Window Management:**
|
- **Window Management:**
|
||||||
- `OpenWindow(spec window.Window) error`: Opens the default window using manager defaults.
|
- `OpenWindow(opts ...WindowOption) error`: Opens a new window with the specified options.
|
||||||
- `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`
|
|
||||||
|
|
||||||
Example:
|
- **Dialogs:**
|
||||||
|
- `ShowEnvironmentDialog()`: Displays a native dialog containing information about the runtime environment (OS, Arch, Debug mode, etc.).
|
||||||
|
|
||||||
```go
|
### `WindowConfig` & `WindowOption`
|
||||||
svc.CreateWindow(window.Window{
|
Window configuration is handled using the Functional Options pattern. The `WindowConfig` struct holds parameters like:
|
||||||
Name: "editor",
|
- `Name`, `Title`
|
||||||
Title: "Editor",
|
- `Width`, `Height`
|
||||||
URL: "/#/editor",
|
- `URL`
|
||||||
Width: 1200,
|
- `AlwaysOnTop`, `Hidden`, `Frameless`
|
||||||
Height: 800,
|
- Window button states (`MinimiseButtonState`, `MaximiseButtonState`, `CloseButtonState`)
|
||||||
})
|
|
||||||
|
|
||||||
svc.SetWindowBounds("editor", 100, 200, 1280, 720)
|
**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)`
|
||||||
|
|
||||||
## Subsystems
|
## Subsystems
|
||||||
|
|
||||||
### Menu (`menu.go`)
|
### Menu (`menu.go`)
|
||||||
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.
|
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).
|
||||||
|
|
||||||
### System Tray (`tray.go`)
|
### System Tray (`tray.go`)
|
||||||
The `setupTray()` method initializes the system tray icon and its context menu. It supports:
|
The `systemTray` method initializes the system tray icon and its context menu. It supports:
|
||||||
- Showing/Hiding all windows.
|
- Showing/Hiding all windows.
|
||||||
- Displaying environment info.
|
- Displaying environment info.
|
||||||
- Quitting the application.
|
- Quitting the application.
|
||||||
- Attaching tray actions that are broadcast as IPC events.
|
- Attaching a hidden window for advanced tray interactions.
|
||||||
|
|
||||||
### Actions (`messages.go`)
|
### Actions (`actions.go`)
|
||||||
Defines structured messages for Inter-Process Communication (IPC) and internal event handling, including `window.ActionWindowOpened`, `window.ActionWindowClosed`, and `display.ActionIDECommand`.
|
Defines structured messages for Inter-Process Communication (IPC) or internal event handling, such as `ActionOpenWindow` which wraps `application.WebviewWindowOptions`.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Development Guide
|
# Development Guide
|
||||||
|
|
||||||
This guide covers how to set up the development environment, build the project, and run the tests.
|
This guide covers how to set up the development environment, build the project, and run the demo.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
|
|
@ -12,7 +12,11 @@ This guide covers how to set up the development environment, build the project,
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
1. Clone the repository and enter the workspace.
|
1. Clone the repository:
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/Snider/display.git
|
||||||
|
cd display
|
||||||
|
```
|
||||||
|
|
||||||
2. Install Go dependencies:
|
2. Install Go dependencies:
|
||||||
```bash
|
```bash
|
||||||
|
|
@ -26,6 +30,23 @@ This guide covers how to set up the development environment, build the project,
|
||||||
cd ..
|
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
|
## Building the Project
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
@ -35,9 +56,9 @@ npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
### Backend / Application
|
### Backend / Application
|
||||||
This package is exercised through Go tests and the host application that embeds it.
|
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.
|
||||||
|
|
||||||
To run the tests:
|
To run the tests:
|
||||||
```bash
|
```bash
|
||||||
go test ./pkg/display/...
|
go test ./...
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,25 @@
|
||||||
# Overview
|
# Overview
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
The project consists of two main parts:
|
The project consists of two main parts:
|
||||||
|
|
||||||
1. **Backend (Go):** Handles window management, tray/menu setup, dialogs, notifications, layout persistence, and IPC dispatch.
|
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 lives in `ui/` and is built as a custom element that talks to the backend through the Wails runtime.
|
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.
|
||||||
|
|
||||||
## Key Components
|
## Key Components
|
||||||
|
|
||||||
### Display Service (`display`)
|
### Display Service (`display`)
|
||||||
The core service manages the application lifecycle and exposes declarative operations such as:
|
The core service that manages the application lifecycle. It wraps the Wails application instance and exposes methods to:
|
||||||
- `OpenWindow(window.Window{})`
|
- Open and configure windows.
|
||||||
- `CreateWindow(window.Window{Name: "editor", URL: "/#/editor"})`
|
- Manage the system tray.
|
||||||
- `SetWindowBounds("editor", 100, 200, 1280, 720)`
|
- Show system dialogs (e.g., environment info).
|
||||||
- `SaveLayout("coding")`
|
|
||||||
- `TileWindows(window.TileModeLeftRight, []string{"editor", "terminal"})`
|
|
||||||
- `ApplyWorkflowLayout(window.WorkflowCoding)`
|
|
||||||
|
|
||||||
### System Integration
|
### System Integration
|
||||||
- **Menu:** The application menu is constructed in `buildMenu()` and dispatched through IPC.
|
- **Menu:** A standard application menu (File, Edit, View, etc.) is constructed in `menu.go`.
|
||||||
- **System Tray:** The tray menu is configured in `setupTray()` and keeps the desktop surface in sync with the runtime.
|
- **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.
|
||||||
- **Events:** Window, theme, screen, lifecycle, and tray actions are broadcast as WebSocket events for the frontend.
|
|
||||||
|
### 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.
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
|
// pkg/display/events.go
|
||||||
package display
|
package display
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -12,6 +13,7 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// EventType represents the type of event.
|
// EventType represents the type of event.
|
||||||
|
// Use: eventType := display.EventWindowFocus
|
||||||
type EventType string
|
type EventType string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -43,6 +45,7 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
// Event represents a display event sent to subscribers.
|
// Event represents a display event sent to subscribers.
|
||||||
|
// Use: evt := display.Event{Type: display.EventWindowFocus, Window: "editor"}
|
||||||
type Event struct {
|
type Event struct {
|
||||||
Type EventType `json:"type"`
|
Type EventType `json:"type"`
|
||||||
Timestamp int64 `json:"timestamp"`
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
|
@ -51,13 +54,23 @@ type Event struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscription represents a client subscription to events.
|
// Subscription represents a client subscription to events.
|
||||||
|
// Use: sub := display.Subscription{ID: "sub-1", EventTypes: []display.EventType{display.EventWindowFocus}}
|
||||||
type Subscription struct {
|
type Subscription struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
EventTypes []EventType `json:"eventTypes"`
|
EventTypes []EventType `json:"eventTypes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebSocketEventManager manages WebSocket connections and event subscriptions.
|
// EventServerInfo summarises the live WebSocket event server state.
|
||||||
type WebSocketEventManager struct {
|
// Use: info := display.EventServerInfo{ConnectedClients: 1, Subscriptions: 3}
|
||||||
|
type EventServerInfo struct {
|
||||||
|
ConnectedClients int `json:"connectedClients"`
|
||||||
|
Subscriptions int `json:"subscriptions"`
|
||||||
|
BufferedEvents int `json:"bufferedEvents"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WSEventManager manages WebSocket connections and event subscriptions.
|
||||||
|
// Use: events := display.NewWSEventManager()
|
||||||
|
type WSEventManager struct {
|
||||||
upgrader websocket.Upgrader
|
upgrader websocket.Upgrader
|
||||||
clients map[*websocket.Conn]*clientState
|
clients map[*websocket.Conn]*clientState
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
|
@ -66,14 +79,16 @@ type WebSocketEventManager struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// clientState tracks a client's subscriptions.
|
// clientState tracks a client's subscriptions.
|
||||||
|
// Use: state := &clientState{subscriptions: map[string]*Subscription{}}
|
||||||
type clientState struct {
|
type clientState struct {
|
||||||
subscriptions map[string]*Subscription
|
subscriptions map[string]*Subscription
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWebSocketEventManager creates a new WebSocket event manager.
|
// NewWSEventManager creates a new event manager.
|
||||||
func NewWebSocketEventManager() *WebSocketEventManager {
|
// Use: events := display.NewWSEventManager()
|
||||||
em := &WebSocketEventManager{
|
func NewWSEventManager() *WSEventManager {
|
||||||
|
em := &WSEventManager{
|
||||||
upgrader: websocket.Upgrader{
|
upgrader: websocket.Upgrader{
|
||||||
CheckOrigin: func(r *http.Request) bool {
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
return true // Allow all origins for local dev
|
return true // Allow all origins for local dev
|
||||||
|
|
@ -92,7 +107,7 @@ func NewWebSocketEventManager() *WebSocketEventManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
// broadcaster sends events to all subscribed clients.
|
// broadcaster sends events to all subscribed clients.
|
||||||
func (em *WebSocketEventManager) broadcaster() {
|
func (em *WSEventManager) broadcaster() {
|
||||||
for event := range em.eventBuffer {
|
for event := range em.eventBuffer {
|
||||||
em.mu.RLock()
|
em.mu.RLock()
|
||||||
for conn, state := range em.clients {
|
for conn, state := range em.clients {
|
||||||
|
|
@ -105,7 +120,7 @@ func (em *WebSocketEventManager) broadcaster() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// clientSubscribed checks if a client is subscribed to an event type.
|
// clientSubscribed checks if a client is subscribed to an event type.
|
||||||
func (em *WebSocketEventManager) clientSubscribed(state *clientState, eventType EventType) bool {
|
func (em *WSEventManager) clientSubscribed(state *clientState, eventType EventType) bool {
|
||||||
state.mu.RLock()
|
state.mu.RLock()
|
||||||
defer state.mu.RUnlock()
|
defer state.mu.RUnlock()
|
||||||
|
|
||||||
|
|
@ -120,7 +135,7 @@ func (em *WebSocketEventManager) clientSubscribed(state *clientState, eventType
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendEvent sends an event to a specific client.
|
// sendEvent sends an event to a specific client.
|
||||||
func (em *WebSocketEventManager) sendEvent(conn *websocket.Conn, event Event) {
|
func (em *WSEventManager) sendEvent(conn *websocket.Conn, event Event) {
|
||||||
em.mu.RLock()
|
em.mu.RLock()
|
||||||
_, exists := em.clients[conn]
|
_, exists := em.clients[conn]
|
||||||
em.mu.RUnlock()
|
em.mu.RUnlock()
|
||||||
|
|
@ -141,7 +156,7 @@ func (em *WebSocketEventManager) sendEvent(conn *websocket.Conn, event Event) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleWebSocket handles WebSocket upgrade and connection.
|
// HandleWebSocket handles WebSocket upgrade and connection.
|
||||||
func (em *WebSocketEventManager) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
func (em *WSEventManager) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
|
||||||
conn, err := em.upgrader.Upgrade(w, r, nil)
|
conn, err := em.upgrader.Upgrade(w, r, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
|
|
@ -158,7 +173,7 @@ func (em *WebSocketEventManager) HandleWebSocket(w http.ResponseWriter, r *http.
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleMessages processes incoming WebSocket messages.
|
// handleMessages processes incoming WebSocket messages.
|
||||||
func (em *WebSocketEventManager) handleMessages(conn *websocket.Conn) {
|
func (em *WSEventManager) handleMessages(conn *websocket.Conn) {
|
||||||
defer em.removeClient(conn)
|
defer em.removeClient(conn)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
|
|
@ -189,7 +204,7 @@ func (em *WebSocketEventManager) handleMessages(conn *websocket.Conn) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// subscribe adds a subscription for a client.
|
// subscribe adds a subscription for a client.
|
||||||
func (em *WebSocketEventManager) subscribe(conn *websocket.Conn, id string, eventTypes []EventType) {
|
func (em *WSEventManager) subscribe(conn *websocket.Conn, id string, eventTypes []EventType) {
|
||||||
em.mu.RLock()
|
em.mu.RLock()
|
||||||
state, exists := em.clients[conn]
|
state, exists := em.clients[conn]
|
||||||
em.mu.RUnlock()
|
em.mu.RUnlock()
|
||||||
|
|
@ -202,7 +217,7 @@ func (em *WebSocketEventManager) subscribe(conn *websocket.Conn, id string, even
|
||||||
if id == "" {
|
if id == "" {
|
||||||
em.mu.Lock()
|
em.mu.Lock()
|
||||||
em.nextSubID++
|
em.nextSubID++
|
||||||
id = "sub-" + strconv.Itoa(em.nextSubID)
|
id = fmt.Sprintf("sub-%d", em.nextSubID)
|
||||||
em.mu.Unlock()
|
em.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -224,7 +239,7 @@ func (em *WebSocketEventManager) subscribe(conn *websocket.Conn, id string, even
|
||||||
}
|
}
|
||||||
|
|
||||||
// unsubscribe removes a subscription for a client.
|
// unsubscribe removes a subscription for a client.
|
||||||
func (em *WebSocketEventManager) unsubscribe(conn *websocket.Conn, id string) {
|
func (em *WSEventManager) unsubscribe(conn *websocket.Conn, id string) {
|
||||||
em.mu.RLock()
|
em.mu.RLock()
|
||||||
state, exists := em.clients[conn]
|
state, exists := em.clients[conn]
|
||||||
em.mu.RUnlock()
|
em.mu.RUnlock()
|
||||||
|
|
@ -247,7 +262,7 @@ func (em *WebSocketEventManager) unsubscribe(conn *websocket.Conn, id string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// listSubscriptions sends a list of active subscriptions to a client.
|
// listSubscriptions sends a list of active subscriptions to a client.
|
||||||
func (em *WebSocketEventManager) listSubscriptions(conn *websocket.Conn) {
|
func (em *WSEventManager) listSubscriptions(conn *websocket.Conn) {
|
||||||
em.mu.RLock()
|
em.mu.RLock()
|
||||||
state, exists := em.clients[conn]
|
state, exists := em.clients[conn]
|
||||||
em.mu.RUnlock()
|
em.mu.RUnlock()
|
||||||
|
|
@ -272,7 +287,7 @@ func (em *WebSocketEventManager) listSubscriptions(conn *websocket.Conn) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// removeClient removes a client and its subscriptions.
|
// removeClient removes a client and its subscriptions.
|
||||||
func (em *WebSocketEventManager) removeClient(conn *websocket.Conn) {
|
func (em *WSEventManager) removeClient(conn *websocket.Conn) {
|
||||||
em.mu.Lock()
|
em.mu.Lock()
|
||||||
delete(em.clients, conn)
|
delete(em.clients, conn)
|
||||||
em.mu.Unlock()
|
em.mu.Unlock()
|
||||||
|
|
@ -280,7 +295,7 @@ func (em *WebSocketEventManager) removeClient(conn *websocket.Conn) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Emit sends an event to all subscribed clients.
|
// Emit sends an event to all subscribed clients.
|
||||||
func (em *WebSocketEventManager) Emit(event Event) {
|
func (em *WSEventManager) Emit(event Event) {
|
||||||
event.Timestamp = time.Now().UnixMilli()
|
event.Timestamp = time.Now().UnixMilli()
|
||||||
select {
|
select {
|
||||||
case em.eventBuffer <- event:
|
case em.eventBuffer <- event:
|
||||||
|
|
@ -290,7 +305,7 @@ func (em *WebSocketEventManager) Emit(event Event) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// EmitWindowEvent is a helper to emit window-related events.
|
// EmitWindowEvent is a helper to emit window-related events.
|
||||||
func (em *WebSocketEventManager) EmitWindowEvent(eventType EventType, windowName string, data map[string]any) {
|
func (em *WSEventManager) EmitWindowEvent(eventType EventType, windowName string, data map[string]any) {
|
||||||
em.Emit(Event{
|
em.Emit(Event{
|
||||||
Type: eventType,
|
Type: eventType,
|
||||||
Window: windowName,
|
Window: windowName,
|
||||||
|
|
@ -299,14 +314,31 @@ func (em *WebSocketEventManager) EmitWindowEvent(eventType EventType, windowName
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConnectedClients returns the number of connected WebSocket clients.
|
// ConnectedClients returns the number of connected WebSocket clients.
|
||||||
func (em *WebSocketEventManager) ConnectedClients() int {
|
func (em *WSEventManager) ConnectedClients() int {
|
||||||
em.mu.RLock()
|
em.mu.RLock()
|
||||||
defer em.mu.RUnlock()
|
defer em.mu.RUnlock()
|
||||||
return len(em.clients)
|
return len(em.clients)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Info returns a snapshot of the WebSocket event server state.
|
||||||
|
func (em *WSEventManager) Info() EventServerInfo {
|
||||||
|
em.mu.RLock()
|
||||||
|
defer em.mu.RUnlock()
|
||||||
|
|
||||||
|
info := EventServerInfo{
|
||||||
|
ConnectedClients: len(em.clients),
|
||||||
|
BufferedEvents: len(em.eventBuffer),
|
||||||
|
}
|
||||||
|
for _, state := range em.clients {
|
||||||
|
state.mu.RLock()
|
||||||
|
info.Subscriptions += len(state.subscriptions)
|
||||||
|
state.mu.RUnlock()
|
||||||
|
}
|
||||||
|
return info
|
||||||
|
}
|
||||||
|
|
||||||
// Close shuts down the event manager.
|
// Close shuts down the event manager.
|
||||||
func (em *WebSocketEventManager) Close() {
|
func (em *WSEventManager) Close() {
|
||||||
em.mu.Lock()
|
em.mu.Lock()
|
||||||
for conn := range em.clients {
|
for conn := range em.clients {
|
||||||
conn.Close()
|
conn.Close()
|
||||||
|
|
@ -318,7 +350,7 @@ func (em *WebSocketEventManager) Close() {
|
||||||
|
|
||||||
// AttachWindowListeners attaches event listeners to a specific window.
|
// AttachWindowListeners attaches event listeners to a specific window.
|
||||||
// Accepts window.PlatformWindow instead of *application.WebviewWindow.
|
// Accepts window.PlatformWindow instead of *application.WebviewWindow.
|
||||||
func (em *WebSocketEventManager) AttachWindowListeners(pw window.PlatformWindow) {
|
func (em *WSEventManager) AttachWindowListeners(pw window.PlatformWindow) {
|
||||||
if pw == nil {
|
if pw == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,16 @@ package display
|
||||||
|
|
||||||
import "github.com/wailsapp/wails/v3/pkg/application"
|
import "github.com/wailsapp/wails/v3/pkg/application"
|
||||||
|
|
||||||
// App abstracts the Wails application for the orchestrator.
|
// App abstracts the Wails application for the display orchestrator.
|
||||||
// After Spec D cleanup, only Quit() and Logger() remain —
|
// The service uses Logger() for diagnostics and Quit() for shutdown.
|
||||||
// all other Wails Manager APIs are accessed via IPC.
|
// Use: var app display.App
|
||||||
type App interface {
|
type App interface {
|
||||||
Logger() Logger
|
Logger() Logger
|
||||||
Quit()
|
Quit()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logger wraps Wails logging.
|
// Logger wraps Wails logging.
|
||||||
|
// Use: var logger display.Logger
|
||||||
type Logger interface {
|
type Logger interface {
|
||||||
Info(message string, args ...any)
|
Info(message string, args ...any)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,17 @@ package display
|
||||||
// ActionIDECommand is broadcast when a menu handler triggers an IDE command
|
// ActionIDECommand is broadcast when a menu handler triggers an IDE command
|
||||||
// (save, run, build). Replaces direct s.app.Event().Emit("ide:*") calls.
|
// (save, run, build). Replaces direct s.app.Event().Emit("ide:*") calls.
|
||||||
// Listeners (e.g. editor windows) handle this via HandleIPCEvents.
|
// Listeners (e.g. editor windows) handle this via HandleIPCEvents.
|
||||||
|
// Use: _ = c.ACTION(display.ActionIDECommand{Command: "save"})
|
||||||
type ActionIDECommand struct {
|
type ActionIDECommand struct {
|
||||||
Command string `json:"command"` // "save", "run", "build"
|
Command string `json:"command"` // "save", "run", "build"
|
||||||
}
|
}
|
||||||
|
|
||||||
// EventIDECommand is the WebSocket event type for IDE commands.
|
// EventIDECommand is the WS event type for IDE commands.
|
||||||
|
// Use: eventType := display.EventIDECommand
|
||||||
const EventIDECommand EventType = "ide.command"
|
const EventIDECommand EventType = "ide.command"
|
||||||
|
|
||||||
|
// Theme is the display-level theme summary exposed by the service API.
|
||||||
|
// Use: theme := display.Theme{IsDark: true}
|
||||||
|
type Theme struct {
|
||||||
|
IsDark bool `json:"isDark"`
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
|
// pkg/dock/register.go
|
||||||
package dock
|
package dock
|
||||||
|
|
||||||
import "forge.lthn.ai/core/go/pkg/core"
|
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) {
|
func Register(p Platform) func(*core.Core) (any, error) {
|
||||||
return func(c *core.Core) (any, error) {
|
return func(c *core.Core) (any, error) {
|
||||||
return &Service{
|
return &Service{
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// pkg/dock/service.go
|
||||||
package dock
|
package dock
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -6,23 +7,30 @@ import (
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
"forge.lthn.ai/core/go/pkg/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Options holds configuration for the dock service.
|
||||||
type Options struct{}
|
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 {
|
type Service struct {
|
||||||
*core.ServiceRuntime[Options]
|
*core.ServiceRuntime[Options]
|
||||||
platform Platform
|
platform Platform
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OnStartup registers IPC handlers.
|
||||||
func (s *Service) OnStartup(ctx context.Context) error {
|
func (s *Service) OnStartup(ctx context.Context) error {
|
||||||
s.Core().RegisterQuery(s.handleQuery)
|
s.Core().RegisterQuery(s.handleQuery)
|
||||||
s.Core().RegisterTask(s.handleTask)
|
s.Core().RegisterTask(s.handleTask)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleIPCEvents is auto-discovered and registered by core.WithService.
|
||||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Query Handlers ---
|
||||||
|
|
||||||
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
||||||
switch q.(type) {
|
switch q.(type) {
|
||||||
case QueryVisible:
|
case QueryVisible:
|
||||||
|
|
@ -32,6 +40,8 @@ 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) {
|
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
||||||
switch t := t.(type) {
|
switch t := t.(type) {
|
||||||
case TaskShowIcon:
|
case TaskShowIcon:
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,13 @@ type TaskOpenFileManager struct {
|
||||||
Select bool `json:"select"`
|
Select bool `json:"select"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TaskSetTheme applies an application theme override when supported.
|
||||||
|
// Theme values: "dark", "light", or "system".
|
||||||
|
type TaskSetTheme struct {
|
||||||
|
Theme string `json:"theme,omitempty"`
|
||||||
|
IsDark bool `json:"isDark,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// ActionThemeChanged is broadcast when the system theme changes.
|
// ActionThemeChanged is broadcast when the system theme changes.
|
||||||
type ActionThemeChanged struct {
|
type ActionThemeChanged struct {
|
||||||
IsDark bool `json:"isDark"`
|
IsDark bool `json:"isDark"`
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package environment
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
"forge.lthn.ai/core/go/pkg/core"
|
||||||
)
|
)
|
||||||
|
|
@ -15,6 +16,7 @@ type Service struct {
|
||||||
*core.ServiceRuntime[Options]
|
*core.ServiceRuntime[Options]
|
||||||
platform Platform
|
platform Platform
|
||||||
cancelTheme func() // cancel function for theme change listener
|
cancelTheme func() // cancel function for theme change listener
|
||||||
|
overrideDark *bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register creates a factory closure that captures the Platform adapter.
|
// Register creates a factory closure that captures the Platform adapter.
|
||||||
|
|
@ -55,7 +57,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) {
|
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
||||||
switch q.(type) {
|
switch q.(type) {
|
||||||
case QueryTheme:
|
case QueryTheme:
|
||||||
isDark := s.platform.IsDarkMode()
|
isDark := s.currentTheme()
|
||||||
theme := "light"
|
theme := "light"
|
||||||
if isDark {
|
if isDark {
|
||||||
theme = "dark"
|
theme = "dark"
|
||||||
|
|
@ -74,7 +76,52 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
||||||
switch t := t.(type) {
|
switch t := t.(type) {
|
||||||
case TaskOpenFileManager:
|
case TaskOpenFileManager:
|
||||||
return nil, true, s.platform.OpenFileManager(t.Path, t.Select)
|
return nil, true, s.platform.OpenFileManager(t.Path, t.Select)
|
||||||
|
case TaskSetTheme:
|
||||||
|
if err := s.taskSetTheme(t); err != nil {
|
||||||
|
return nil, true, err
|
||||||
|
}
|
||||||
|
return nil, true, nil
|
||||||
default:
|
default:
|
||||||
return nil, false, nil
|
return nil, false, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) taskSetTheme(task TaskSetTheme) error {
|
||||||
|
shouldApplyTheme := false
|
||||||
|
switch task.Theme {
|
||||||
|
case "dark":
|
||||||
|
isDark := true
|
||||||
|
s.overrideDark = &isDark
|
||||||
|
shouldApplyTheme = true
|
||||||
|
case "light":
|
||||||
|
isDark := false
|
||||||
|
s.overrideDark = &isDark
|
||||||
|
shouldApplyTheme = true
|
||||||
|
case "system":
|
||||||
|
s.overrideDark = nil
|
||||||
|
case "":
|
||||||
|
isDark := task.IsDark
|
||||||
|
s.overrideDark = &isDark
|
||||||
|
shouldApplyTheme = true
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid theme mode: %s", task.Theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldApplyTheme {
|
||||||
|
if setter, ok := s.platform.(interface{ SetTheme(bool) error }); ok {
|
||||||
|
if err := setter.SetTheme(s.currentTheme()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = s.Core().ACTION(ActionThemeChanged{IsDark: s.currentTheme()})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) currentTheme() bool {
|
||||||
|
if s.overrideDark != nil {
|
||||||
|
return *s.overrideDark
|
||||||
|
}
|
||||||
|
return s.platform.IsDarkMode()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,8 @@ type mockPlatform struct {
|
||||||
accentColour string
|
accentColour string
|
||||||
openFMErr error
|
openFMErr error
|
||||||
themeHandler func(isDark bool)
|
themeHandler func(isDark bool)
|
||||||
|
setThemeSeen bool
|
||||||
|
setThemeDark bool
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -36,6 +38,12 @@ func (m *mockPlatform) OnThemeChange(handler func(isDark bool)) func() {
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
func (m *mockPlatform) SetTheme(isDark bool) error {
|
||||||
|
m.setThemeSeen = true
|
||||||
|
m.setThemeDark = isDark
|
||||||
|
m.isDark = isDark
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// simulateThemeChange triggers the stored handler (test helper).
|
// simulateThemeChange triggers the stored handler (test helper).
|
||||||
func (m *mockPlatform) simulateThemeChange(isDark bool) {
|
func (m *mockPlatform) simulateThemeChange(isDark bool) {
|
||||||
|
|
@ -131,3 +139,33 @@ func TestThemeChange_ActionBroadcast_Good(t *testing.T) {
|
||||||
require.NotNil(t, r)
|
require.NotNil(t, r)
|
||||||
assert.False(t, r.IsDark)
|
assert.False(t, r.IsDark)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTaskSetTheme_Good(t *testing.T) {
|
||||||
|
mock, c := newTestService(t)
|
||||||
|
_, handled, err := c.PERFORM(TaskSetTheme{Theme: "light"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
assert.True(t, mock.setThemeSeen)
|
||||||
|
|
||||||
|
result, handled, err := c.QUERY(QueryTheme{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
theme := result.(ThemeInfo)
|
||||||
|
assert.False(t, theme.IsDark)
|
||||||
|
assert.Equal(t, "light", theme.Theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskSetTheme_Compatibility_Good(t *testing.T) {
|
||||||
|
mock, c := newTestService(t)
|
||||||
|
_, handled, err := c.PERFORM(TaskSetTheme{IsDark: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
assert.True(t, mock.setThemeSeen)
|
||||||
|
|
||||||
|
result, handled, err := c.QUERY(QueryTheme{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
theme := result.(ThemeInfo)
|
||||||
|
assert.True(t, theme.IsDark)
|
||||||
|
assert.Equal(t, "dark", theme.Theme)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,40 @@
|
||||||
|
// pkg/keybinding/messages.go
|
||||||
package keybinding
|
package keybinding
|
||||||
|
|
||||||
import "errors"
|
import "errors"
|
||||||
|
|
||||||
var ErrorAlreadyRegistered = errors.New("keybinding: accelerator already registered")
|
// 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")
|
||||||
|
|
||||||
|
// BindingInfo describes a registered keyboard shortcut.
|
||||||
type BindingInfo struct {
|
type BindingInfo struct {
|
||||||
Accelerator string `json:"accelerator"`
|
Accelerator string `json:"accelerator"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Queries ---
|
||||||
|
|
||||||
|
// QueryList returns all registered bindings. Result: []BindingInfo
|
||||||
type QueryList struct{}
|
type QueryList struct{}
|
||||||
|
|
||||||
|
// --- Tasks ---
|
||||||
|
|
||||||
|
// TaskAdd registers a new keyboard shortcut. Result: nil
|
||||||
|
// Returns ErrAlreadyRegistered if the accelerator is already bound.
|
||||||
type TaskAdd struct {
|
type TaskAdd struct {
|
||||||
Accelerator string `json:"accelerator"`
|
Accelerator string `json:"accelerator"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TaskRemove unregisters a keyboard shortcut. Result: nil
|
||||||
type TaskRemove struct {
|
type TaskRemove struct {
|
||||||
Accelerator string `json:"accelerator"`
|
Accelerator string `json:"accelerator"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Actions ---
|
||||||
|
|
||||||
|
// ActionTriggered is broadcast when a registered shortcut is activated.
|
||||||
type ActionTriggered struct {
|
type ActionTriggered struct {
|
||||||
Accelerator string `json:"accelerator"`
|
Accelerator string `json:"accelerator"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
|
// pkg/keybinding/register.go
|
||||||
package keybinding
|
package keybinding
|
||||||
|
|
||||||
import "forge.lthn.ai/core/go/pkg/core"
|
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) {
|
func Register(p Platform) func(*core.Core) (any, error) {
|
||||||
return func(c *core.Core) (any, error) {
|
return func(c *core.Core) (any, error) {
|
||||||
return &Service{
|
return &Service{
|
||||||
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
||||||
platform: p,
|
platform: p,
|
||||||
registeredBindings: make(map[string]BindingInfo),
|
bindings: make(map[string]BindingInfo),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,25 +3,31 @@ package keybinding
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
"forge.lthn.ai/core/go/pkg/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Options holds configuration for the keybinding service.
|
||||||
type Options struct{}
|
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 {
|
type Service struct {
|
||||||
*core.ServiceRuntime[Options]
|
*core.ServiceRuntime[Options]
|
||||||
platform Platform
|
platform Platform
|
||||||
registeredBindings map[string]BindingInfo
|
bindings map[string]BindingInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OnStartup registers IPC handlers.
|
||||||
func (s *Service) OnStartup(ctx context.Context) error {
|
func (s *Service) OnStartup(ctx context.Context) error {
|
||||||
s.Core().RegisterQuery(s.handleQuery)
|
s.Core().RegisterQuery(s.handleQuery)
|
||||||
s.Core().RegisterTask(s.handleTask)
|
s.Core().RegisterTask(s.handleTask)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleIPCEvents is auto-discovered and registered by core.WithService.
|
||||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -37,9 +43,10 @@ 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 {
|
func (s *Service) queryList() []BindingInfo {
|
||||||
result := make([]BindingInfo, 0, len(s.registeredBindings))
|
result := make([]BindingInfo, 0, len(s.bindings))
|
||||||
for _, info := range s.registeredBindings {
|
for _, info := range s.bindings {
|
||||||
result = append(result, info)
|
result = append(result, info)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
|
|
@ -59,8 +66,8 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) taskAdd(t TaskAdd) error {
|
func (s *Service) taskAdd(t TaskAdd) error {
|
||||||
if _, exists := s.registeredBindings[t.Accelerator]; exists {
|
if _, exists := s.bindings[t.Accelerator]; exists {
|
||||||
return ErrorAlreadyRegistered
|
return ErrAlreadyRegistered
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register on platform with a callback that broadcasts ActionTriggered
|
// Register on platform with a callback that broadcasts ActionTriggered
|
||||||
|
|
@ -68,10 +75,10 @@ func (s *Service) taskAdd(t TaskAdd) error {
|
||||||
_ = s.Core().ACTION(ActionTriggered{Accelerator: t.Accelerator})
|
_ = s.Core().ACTION(ActionTriggered{Accelerator: t.Accelerator})
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return coreerr.E("keybinding.taskAdd", "platform add failed", err)
|
return fmt.Errorf("keybinding: platform add failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.registeredBindings[t.Accelerator] = BindingInfo{
|
s.bindings[t.Accelerator] = BindingInfo{
|
||||||
Accelerator: t.Accelerator,
|
Accelerator: t.Accelerator,
|
||||||
Description: t.Description,
|
Description: t.Description,
|
||||||
}
|
}
|
||||||
|
|
@ -79,15 +86,15 @@ func (s *Service) taskAdd(t TaskAdd) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) taskRemove(t TaskRemove) error {
|
func (s *Service) taskRemove(t TaskRemove) error {
|
||||||
if _, exists := s.registeredBindings[t.Accelerator]; !exists {
|
if _, exists := s.bindings[t.Accelerator]; !exists {
|
||||||
return coreerr.E("keybinding.taskRemove", "not registered: "+t.Accelerator, nil)
|
return fmt.Errorf("keybinding: not registered: %s", t.Accelerator)
|
||||||
}
|
}
|
||||||
|
|
||||||
err := s.platform.Remove(t.Accelerator)
|
err := s.platform.Remove(t.Accelerator)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return coreerr.E("keybinding.taskRemove", "platform remove failed", err)
|
return fmt.Errorf("keybinding: platform remove failed: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(s.registeredBindings, t.Accelerator)
|
delete(s.bindings, t.Accelerator)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,7 @@ func TestTaskAdd_Bad_Duplicate(t *testing.T) {
|
||||||
// Second add with same accelerator should fail
|
// Second add with same accelerator should fail
|
||||||
_, handled, err := c.PERFORM(TaskAdd{Accelerator: "Ctrl+S", Description: "Save Again"})
|
_, handled, err := c.PERFORM(TaskAdd{Accelerator: "Ctrl+S", Description: "Save Again"})
|
||||||
assert.True(t, handled)
|
assert.True(t, handled)
|
||||||
assert.ErrorIs(t, err, ErrorAlreadyRegistered)
|
assert.ErrorIs(t, err, ErrAlreadyRegistered)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskRemove_Good(t *testing.T) {
|
func TestTaskRemove_Good(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
|
// pkg/lifecycle/register.go
|
||||||
package lifecycle
|
package lifecycle
|
||||||
|
|
||||||
import "forge.lthn.ai/core/go/pkg/core"
|
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) {
|
func Register(p Platform) func(*core.Core) (any, error) {
|
||||||
return func(c *core.Core) (any, error) {
|
return func(c *core.Core) (any, error) {
|
||||||
return &Service{
|
return &Service{
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// pkg/lifecycle/service.go
|
||||||
package lifecycle
|
package lifecycle
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -6,15 +7,22 @@ import (
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
"forge.lthn.ai/core/go/pkg/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Options holds configuration for the lifecycle service.
|
||||||
type Options struct{}
|
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 {
|
type Service struct {
|
||||||
*core.ServiceRuntime[Options]
|
*core.ServiceRuntime[Options]
|
||||||
platform Platform
|
platform Platform
|
||||||
cancels []func()
|
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 {
|
func (s *Service) OnStartup(ctx context.Context) error {
|
||||||
|
// Register fire-and-forget event callbacks
|
||||||
eventActions := map[EventType]func(){
|
eventActions := map[EventType]func(){
|
||||||
EventApplicationStarted: func() { _ = s.Core().ACTION(ActionApplicationStarted{}) },
|
EventApplicationStarted: func() { _ = s.Core().ACTION(ActionApplicationStarted{}) },
|
||||||
EventWillTerminate: func() { _ = s.Core().ACTION(ActionWillTerminate{}) },
|
EventWillTerminate: func() { _ = s.Core().ACTION(ActionWillTerminate{}) },
|
||||||
|
|
@ -30,6 +38,7 @@ func (s *Service) OnStartup(ctx context.Context) error {
|
||||||
s.cancels = append(s.cancels, cancel)
|
s.cancels = append(s.cancels, cancel)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register file-open callback (carries data)
|
||||||
cancel := s.platform.OnOpenedWithFile(func(path string) {
|
cancel := s.platform.OnOpenedWithFile(func(path string) {
|
||||||
_ = s.Core().ACTION(ActionOpenedWithFile{Path: path})
|
_ = s.Core().ACTION(ActionOpenedWithFile{Path: path})
|
||||||
})
|
})
|
||||||
|
|
@ -38,6 +47,7 @@ func (s *Service) OnStartup(ctx context.Context) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OnShutdown cancels all registered platform callbacks.
|
||||||
func (s *Service) OnShutdown(ctx context.Context) error {
|
func (s *Service) OnShutdown(ctx context.Context) error {
|
||||||
for _, cancel := range s.cancels {
|
for _, cancel := range s.cancels {
|
||||||
cancel()
|
cancel()
|
||||||
|
|
@ -46,6 +56,8 @@ func (s *Service) OnShutdown(ctx context.Context) error {
|
||||||
return nil
|
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 {
|
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,11 @@ import (
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
"forge.lthn.ai/core/go/pkg/core"
|
||||||
"forge.lthn.ai/core/gui/pkg/clipboard"
|
"forge.lthn.ai/core/gui/pkg/clipboard"
|
||||||
|
"forge.lthn.ai/core/gui/pkg/display"
|
||||||
|
"forge.lthn.ai/core/gui/pkg/environment"
|
||||||
|
"forge.lthn.ai/core/gui/pkg/notification"
|
||||||
|
"forge.lthn.ai/core/gui/pkg/screen"
|
||||||
|
"forge.lthn.ai/core/gui/pkg/webview"
|
||||||
"forge.lthn.ai/core/gui/pkg/window"
|
"forge.lthn.ai/core/gui/pkg/window"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
|
@ -15,13 +20,13 @@ import (
|
||||||
|
|
||||||
func TestSubsystem_Good_Name(t *testing.T) {
|
func TestSubsystem_Good_Name(t *testing.T) {
|
||||||
c, _ := core.New(core.WithServiceLock())
|
c, _ := core.New(core.WithServiceLock())
|
||||||
sub := NewService(c)
|
sub := New(c)
|
||||||
assert.Equal(t, "display", sub.Name())
|
assert.Equal(t, "display", sub.Name())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSubsystem_Good_RegisterTools(t *testing.T) {
|
func TestSubsystem_Good_RegisterTools(t *testing.T) {
|
||||||
c, _ := core.New(core.WithServiceLock())
|
c, _ := core.New(core.WithServiceLock())
|
||||||
sub := NewService(c)
|
sub := New(c)
|
||||||
// RegisterTools should not panic with a real mcp.Server
|
// RegisterTools should not panic with a real mcp.Server
|
||||||
server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.1.0"}, nil)
|
server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.1.0"}, nil)
|
||||||
assert.NotPanics(t, func() { sub.RegisterTools(server) })
|
assert.NotPanics(t, func() { sub.RegisterTools(server) })
|
||||||
|
|
@ -37,6 +42,56 @@ type mockClipPlatform struct {
|
||||||
func (m *mockClipPlatform) Text() (string, bool) { return m.text, m.ok }
|
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 }
|
||||||
|
|
||||||
|
type mockNotificationPlatform struct {
|
||||||
|
sendCalled bool
|
||||||
|
lastOpts notification.NotificationOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockNotificationPlatform) Send(opts notification.NotificationOptions) error {
|
||||||
|
m.sendCalled = true
|
||||||
|
m.lastOpts = opts
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *mockNotificationPlatform) RequestPermission() (bool, error) { return true, nil }
|
||||||
|
func (m *mockNotificationPlatform) CheckPermission() (bool, error) { return true, nil }
|
||||||
|
|
||||||
|
type mockEnvironmentPlatform struct {
|
||||||
|
isDark bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockEnvironmentPlatform) IsDarkMode() bool { return m.isDark }
|
||||||
|
func (m *mockEnvironmentPlatform) Info() environment.EnvironmentInfo {
|
||||||
|
return environment.EnvironmentInfo{}
|
||||||
|
}
|
||||||
|
func (m *mockEnvironmentPlatform) AccentColour() string { return "" }
|
||||||
|
func (m *mockEnvironmentPlatform) OpenFileManager(path string, selectFile bool) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (m *mockEnvironmentPlatform) OnThemeChange(handler func(isDark bool)) func() {
|
||||||
|
return func() {}
|
||||||
|
}
|
||||||
|
func (m *mockEnvironmentPlatform) SetTheme(isDark bool) error {
|
||||||
|
m.isDark = isDark
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(m.screens) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &m.screens[0]
|
||||||
|
}
|
||||||
|
|
||||||
func TestMCP_Good_ClipboardRoundTrip(t *testing.T) {
|
func TestMCP_Good_ClipboardRoundTrip(t *testing.T) {
|
||||||
c, err := core.New(
|
c, err := core.New(
|
||||||
core.WithService(clipboard.Register(&mockClipPlatform{text: "hello", ok: true})),
|
core.WithService(clipboard.Register(&mockClipPlatform{text: "hello", ok: true})),
|
||||||
|
|
@ -54,13 +109,169 @@ func TestMCP_Good_ClipboardRoundTrip(t *testing.T) {
|
||||||
assert.Equal(t, "hello", content.Text)
|
assert.Equal(t, "hello", content.Text)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMCP_Bad_WindowCreateRequiresName(t *testing.T) {
|
func TestMCP_Good_DialogMessage(t *testing.T) {
|
||||||
c, _ := core.New(core.WithServiceLock())
|
mock := &mockNotificationPlatform{}
|
||||||
sub := NewService(c)
|
c, err := core.New(
|
||||||
|
core.WithService(notification.Register(mock)),
|
||||||
|
core.WithServiceLock(),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
||||||
|
|
||||||
_, _, err := sub.windowCreate(context.Background(), nil, window.Window{})
|
sub := New(c)
|
||||||
require.Error(t, err)
|
_, result, err := sub.dialogMessage(context.Background(), nil, DialogMessageInput{
|
||||||
assert.Contains(t, err.Error(), "window name is required")
|
Title: "Alias",
|
||||||
|
Message: "Hello",
|
||||||
|
Kind: "error",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, result.Success)
|
||||||
|
assert.True(t, mock.sendCalled)
|
||||||
|
assert.Equal(t, notification.SeverityError, mock.lastOpts.Severity)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCP_Good_ThemeSetString(t *testing.T) {
|
||||||
|
mock := &mockEnvironmentPlatform{isDark: true}
|
||||||
|
c, err := core.New(
|
||||||
|
core.WithService(environment.Register(mock)),
|
||||||
|
core.WithServiceLock(),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
||||||
|
|
||||||
|
sub := New(c)
|
||||||
|
_, result, err := sub.themeSet(context.Background(), nil, ThemeSetInput{Theme: "light"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "light", result.Theme.Theme)
|
||||||
|
assert.False(t, result.Theme.IsDark)
|
||||||
|
assert.False(t, mock.isDark)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCP_Good_WindowTitleSetAlias(t *testing.T) {
|
||||||
|
c, err := core.New(
|
||||||
|
core.WithService(window.Register(window.NewMockPlatform())),
|
||||||
|
core.WithServiceLock(),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
||||||
|
|
||||||
|
_, handled, err := c.PERFORM(window.TaskOpenWindow{
|
||||||
|
Window: &window.Window{Name: "alias-win", Title: "Original", URL: "/"},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
|
||||||
|
sub := New(c)
|
||||||
|
_, result, err := sub.windowTitleSet(context.Background(), nil, WindowTitleInput{
|
||||||
|
Name: "alias-win",
|
||||||
|
Title: "Updated",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, result.Success)
|
||||||
|
|
||||||
|
queried, handled, err := c.QUERY(window.QueryWindowByName{Name: "alias-win"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
info, ok := queried.(*window.WindowInfo)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.NotNil(t, info)
|
||||||
|
assert.Equal(t, "Updated", info.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCP_Good_ScreenWorkAreaAlias(t *testing.T) {
|
||||||
|
c, err := core.New(
|
||||||
|
core.WithService(screen.Register(&mockScreenPlatform{
|
||||||
|
screens: []screen.Screen{
|
||||||
|
{
|
||||||
|
ID: "1",
|
||||||
|
Name: "Primary",
|
||||||
|
IsPrimary: true,
|
||||||
|
WorkArea: screen.Rect{X: 0, Y: 24, Width: 1920, Height: 1056},
|
||||||
|
Bounds: screen.Rect{X: 0, Y: 0, Width: 1920, Height: 1080},
|
||||||
|
Size: screen.Size{Width: 1920, Height: 1080},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
core.WithServiceLock(),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
||||||
|
|
||||||
|
sub := New(c)
|
||||||
|
_, plural, err := sub.screenWorkAreas(context.Background(), nil, ScreenWorkAreasInput{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, alias, err := sub.screenWorkArea(context.Background(), nil, ScreenWorkAreasInput{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, plural, alias)
|
||||||
|
assert.Len(t, alias.WorkAreas, 1)
|
||||||
|
assert.Equal(t, 24, alias.WorkAreas[0].Y)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCP_Good_ScreenForWindow(t *testing.T) {
|
||||||
|
c, err := core.New(
|
||||||
|
core.WithService(display.Register(nil)),
|
||||||
|
core.WithService(screen.Register(&mockScreenPlatform{
|
||||||
|
screens: []screen.Screen{
|
||||||
|
{
|
||||||
|
ID: "1",
|
||||||
|
Name: "Primary",
|
||||||
|
IsPrimary: true,
|
||||||
|
WorkArea: screen.Rect{X: 0, Y: 0, Width: 1920, Height: 1080},
|
||||||
|
Bounds: screen.Rect{X: 0, Y: 0, Width: 1920, Height: 1080},
|
||||||
|
Size: screen.Size{Width: 1920, Height: 1080},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "2",
|
||||||
|
Name: "Secondary",
|
||||||
|
WorkArea: screen.Rect{X: 1920, Y: 0, Width: 1280, Height: 1024},
|
||||||
|
Bounds: screen.Rect{X: 1920, Y: 0, Width: 1280, Height: 1024},
|
||||||
|
Size: screen.Size{Width: 1280, Height: 1024},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
core.WithService(window.Register(window.NewMockPlatform())),
|
||||||
|
core.WithServiceLock(),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
||||||
|
|
||||||
|
_, handled, err := c.PERFORM(window.TaskOpenWindow{
|
||||||
|
Window: &window.Window{Name: "editor", Title: "Editor", X: 100, Y: 100, Width: 800, Height: 600},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
|
||||||
|
sub := New(c)
|
||||||
|
_, out, err := sub.screenForWindow(context.Background(), nil, ScreenForWindowInput{Window: "editor"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, out.Screen)
|
||||||
|
assert.Equal(t, "Primary", out.Screen.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMCP_Good_WebviewErrors(t *testing.T) {
|
||||||
|
c, err := core.New(
|
||||||
|
core.WithService(webview.Register(webview.Options{})),
|
||||||
|
core.WithServiceLock(),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
||||||
|
|
||||||
|
require.NoError(t, c.ACTION(webview.ActionException{
|
||||||
|
Window: "main",
|
||||||
|
Exception: webview.ExceptionInfo{
|
||||||
|
Text: "boom",
|
||||||
|
URL: "https://example.com/app.js",
|
||||||
|
Line: 12,
|
||||||
|
Column: 4,
|
||||||
|
StackTrace: "Error: boom",
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
sub := New(c)
|
||||||
|
_, out, err := sub.webviewErrors(context.Background(), nil, WebviewErrorsInput{Window: "main"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, out.Errors, 1)
|
||||||
|
assert.Equal(t, "boom", out.Errors[0].Text)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMCP_Bad_NoServices(t *testing.T) {
|
func TestMCP_Bad_NoServices(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ type Subsystem struct {
|
||||||
core *core.Core
|
core *core.Core
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a display MCP subsystem backed by the given Core instance.
|
// New creates a display MCP subsystem backed by the given Core instance.
|
||||||
func NewService(c *core.Core) *Subsystem {
|
func New(c *core.Core) *Subsystem {
|
||||||
return &Subsystem{core: c}
|
return &Subsystem{core: c}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
|
||||||
"forge.lthn.ai/core/gui/pkg/clipboard"
|
"forge.lthn.ai/core/gui/pkg/clipboard"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
@ -23,7 +23,7 @@ func (s *Subsystem) clipboardRead(_ context.Context, _ *mcp.CallToolRequest, _ C
|
||||||
}
|
}
|
||||||
content, ok := result.(clipboard.ClipboardContent)
|
content, ok := result.(clipboard.ClipboardContent)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, ClipboardReadOutput{}, coreerr.E("mcp.clipboardRead", "unexpected result type", nil)
|
return nil, ClipboardReadOutput{}, fmt.Errorf("unexpected result type from clipboard read query")
|
||||||
}
|
}
|
||||||
return nil, ClipboardReadOutput{Content: content.Text}, 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)
|
success, ok := result.(bool)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, ClipboardWriteOutput{}, coreerr.E("mcp.clipboardWrite", "unexpected result type", nil)
|
return nil, ClipboardWriteOutput{}, fmt.Errorf("unexpected result type from clipboard write task")
|
||||||
}
|
}
|
||||||
return nil, ClipboardWriteOutput{Success: success}, 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)
|
content, ok := result.(clipboard.ClipboardContent)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, ClipboardHasOutput{}, coreerr.E("mcp.clipboardHas", "unexpected result type", nil)
|
return nil, ClipboardHasOutput{}, fmt.Errorf("unexpected result type from clipboard has query")
|
||||||
}
|
}
|
||||||
return nil, ClipboardHasOutput{HasContent: content.HasContent}, nil
|
return nil, ClipboardHasOutput{HasContent: content.HasContent}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -82,11 +82,47 @@ func (s *Subsystem) clipboardClear(_ context.Context, _ *mcp.CallToolRequest, _
|
||||||
}
|
}
|
||||||
success, ok := result.(bool)
|
success, ok := result.(bool)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, ClipboardClearOutput{}, coreerr.E("mcp.clipboardClear", "unexpected result type", nil)
|
return nil, ClipboardClearOutput{}, fmt.Errorf("unexpected result type from clipboard clear task")
|
||||||
}
|
}
|
||||||
return nil, ClipboardClearOutput{Success: success}, nil
|
return nil, ClipboardClearOutput{Success: success}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- clipboard_read_image ---
|
||||||
|
|
||||||
|
type ClipboardReadImageInput struct{}
|
||||||
|
type ClipboardReadImageOutput struct {
|
||||||
|
Image clipboard.ClipboardImageContent `json:"image"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) clipboardReadImage(_ context.Context, _ *mcp.CallToolRequest, _ ClipboardReadImageInput) (*mcp.CallToolResult, ClipboardReadImageOutput, error) {
|
||||||
|
result, _, err := s.core.QUERY(clipboard.QueryImage{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, ClipboardReadImageOutput{}, err
|
||||||
|
}
|
||||||
|
image, ok := result.(clipboard.ClipboardImageContent)
|
||||||
|
if !ok {
|
||||||
|
return nil, ClipboardReadImageOutput{}, fmt.Errorf("unexpected result type from clipboard image query")
|
||||||
|
}
|
||||||
|
return nil, ClipboardReadImageOutput{Image: image}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- clipboard_write_image ---
|
||||||
|
|
||||||
|
type ClipboardWriteImageInput struct {
|
||||||
|
Data []byte `json:"data"`
|
||||||
|
}
|
||||||
|
type ClipboardWriteImageOutput struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) clipboardWriteImage(_ context.Context, _ *mcp.CallToolRequest, input ClipboardWriteImageInput) (*mcp.CallToolResult, ClipboardWriteImageOutput, error) {
|
||||||
|
_, _, err := s.core.PERFORM(clipboard.TaskSetImage{Data: input.Data})
|
||||||
|
if err != nil {
|
||||||
|
return nil, ClipboardWriteImageOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, ClipboardWriteImageOutput{Success: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// --- Registration ---
|
// --- Registration ---
|
||||||
|
|
||||||
func (s *Subsystem) registerClipboardTools(server *mcp.Server) {
|
func (s *Subsystem) registerClipboardTools(server *mcp.Server) {
|
||||||
|
|
@ -94,4 +130,6 @@ func (s *Subsystem) registerClipboardTools(server *mcp.Server) {
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "clipboard_write", Description: "Write text to the clipboard"}, s.clipboardWrite)
|
mcp.AddTool(server, &mcp.Tool{Name: "clipboard_write", Description: "Write text to the clipboard"}, s.clipboardWrite)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "clipboard_has", Description: "Check if the clipboard has content"}, s.clipboardHas)
|
mcp.AddTool(server, &mcp.Tool{Name: "clipboard_has", Description: "Check if the clipboard has content"}, s.clipboardHas)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "clipboard_clear", Description: "Clear the clipboard"}, s.clipboardClear)
|
mcp.AddTool(server, &mcp.Tool{Name: "clipboard_clear", Description: "Clear the clipboard"}, s.clipboardClear)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "clipboard_read_image", Description: "Read an image from the clipboard"}, s.clipboardReadImage)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "clipboard_write_image", Description: "Write an image to the clipboard"}, s.clipboardWriteImage)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@ package mcp
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
|
||||||
"forge.lthn.ai/core/gui/pkg/contextmenu"
|
"forge.lthn.ai/core/gui/pkg/contextmenu"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"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
|
// Convert map[string]any to ContextMenuDef via JSON round-trip
|
||||||
menuJSON, err := json.Marshal(input.Menu)
|
menuJSON, err := json.Marshal(input.Menu)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, ContextMenuAddOutput{}, coreerr.E("mcp.contextMenuAdd", "failed to marshal menu definition", err)
|
return nil, ContextMenuAddOutput{}, fmt.Errorf("failed to marshal menu definition: %w", err)
|
||||||
}
|
}
|
||||||
var menuDef contextmenu.ContextMenuDef
|
var menuDef contextmenu.ContextMenuDef
|
||||||
if err := json.Unmarshal(menuJSON, &menuDef); err != nil {
|
if err := json.Unmarshal(menuJSON, &menuDef); err != nil {
|
||||||
return nil, ContextMenuAddOutput{}, coreerr.E("mcp.contextMenuAdd", "failed to unmarshal menu definition", err)
|
return nil, ContextMenuAddOutput{}, fmt.Errorf("failed to unmarshal menu definition: %w", err)
|
||||||
}
|
}
|
||||||
_, _, err = s.core.PERFORM(contextmenu.TaskAdd{Name: input.Name, Menu: menuDef})
|
_, _, err = s.core.PERFORM(contextmenu.TaskAdd{Name: input.Name, Menu: menuDef})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -73,7 +73,7 @@ func (s *Subsystem) contextMenuGet(_ context.Context, _ *mcp.CallToolRequest, in
|
||||||
}
|
}
|
||||||
menu, ok := result.(*contextmenu.ContextMenuDef)
|
menu, ok := result.(*contextmenu.ContextMenuDef)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, ContextMenuGetOutput{}, coreerr.E("mcp.contextMenuGet", "unexpected result type", nil)
|
return nil, ContextMenuGetOutput{}, fmt.Errorf("unexpected result type from context menu get query")
|
||||||
}
|
}
|
||||||
if menu == nil {
|
if menu == nil {
|
||||||
return nil, ContextMenuGetOutput{}, 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
|
// Convert to map[string]any via JSON round-trip to avoid cyclic type in schema
|
||||||
menuJSON, err := json.Marshal(menu)
|
menuJSON, err := json.Marshal(menu)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, ContextMenuGetOutput{}, coreerr.E("mcp.contextMenuGet", "failed to marshal context menu", err)
|
return nil, ContextMenuGetOutput{}, fmt.Errorf("failed to marshal context menu: %w", err)
|
||||||
}
|
}
|
||||||
var menuMap map[string]any
|
var menuMap map[string]any
|
||||||
if err := json.Unmarshal(menuJSON, &menuMap); err != nil {
|
if err := json.Unmarshal(menuJSON, &menuMap); err != nil {
|
||||||
return nil, ContextMenuGetOutput{}, coreerr.E("mcp.contextMenuGet", "failed to unmarshal context menu", err)
|
return nil, ContextMenuGetOutput{}, fmt.Errorf("failed to unmarshal context menu: %w", err)
|
||||||
}
|
}
|
||||||
return nil, ContextMenuGetOutput{Menu: menuMap}, nil
|
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)
|
menus, ok := result.(map[string]contextmenu.ContextMenuDef)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, ContextMenuListOutput{}, coreerr.E("mcp.contextMenuList", "unexpected result type", nil)
|
return nil, ContextMenuListOutput{}, fmt.Errorf("unexpected result type from context menu list query")
|
||||||
}
|
}
|
||||||
// Convert to map[string]any via JSON round-trip to avoid cyclic type in schema
|
// Convert to map[string]any via JSON round-trip to avoid cyclic type in schema
|
||||||
menusJSON, err := json.Marshal(menus)
|
menusJSON, err := json.Marshal(menus)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, ContextMenuListOutput{}, coreerr.E("mcp.contextMenuList", "failed to marshal context menus", err)
|
return nil, ContextMenuListOutput{}, fmt.Errorf("failed to marshal context menus: %w", err)
|
||||||
}
|
}
|
||||||
var menusMap map[string]any
|
var menusMap map[string]any
|
||||||
if err := json.Unmarshal(menusJSON, &menusMap); err != nil {
|
if err := json.Unmarshal(menusJSON, &menusMap); err != nil {
|
||||||
return nil, ContextMenuListOutput{}, coreerr.E("mcp.contextMenuList", "failed to unmarshal context menus", err)
|
return nil, ContextMenuListOutput{}, fmt.Errorf("failed to unmarshal context menus: %w", err)
|
||||||
}
|
}
|
||||||
return nil, ContextMenuListOutput{Menus: menusMap}, nil
|
return nil, ContextMenuListOutput{Menus: menusMap}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
|
||||||
"forge.lthn.ai/core/gui/pkg/dialog"
|
"forge.lthn.ai/core/gui/pkg/dialog"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"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) {
|
func (s *Subsystem) dialogOpenFile(_ context.Context, _ *mcp.CallToolRequest, input DialogOpenFileInput) (*mcp.CallToolResult, DialogOpenFileOutput, error) {
|
||||||
result, _, err := s.core.PERFORM(dialog.TaskOpenFile{Options: dialog.OpenFileOptions{
|
result, _, err := s.core.PERFORM(dialog.TaskOpenFile{Opts: dialog.OpenFileOptions{
|
||||||
Title: input.Title,
|
Title: input.Title,
|
||||||
Directory: input.Directory,
|
Directory: input.Directory,
|
||||||
Filters: input.Filters,
|
Filters: input.Filters,
|
||||||
|
|
@ -33,7 +33,7 @@ func (s *Subsystem) dialogOpenFile(_ context.Context, _ *mcp.CallToolRequest, in
|
||||||
}
|
}
|
||||||
paths, ok := result.([]string)
|
paths, ok := result.([]string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, DialogOpenFileOutput{}, coreerr.E("mcp.dialogOpenFile", "unexpected result type", nil)
|
return nil, DialogOpenFileOutput{}, fmt.Errorf("unexpected result type from open file dialog")
|
||||||
}
|
}
|
||||||
return nil, DialogOpenFileOutput{Paths: paths}, 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) {
|
func (s *Subsystem) dialogSaveFile(_ context.Context, _ *mcp.CallToolRequest, input DialogSaveFileInput) (*mcp.CallToolResult, DialogSaveFileOutput, error) {
|
||||||
result, _, err := s.core.PERFORM(dialog.TaskSaveFile{Options: dialog.SaveFileOptions{
|
result, _, err := s.core.PERFORM(dialog.TaskSaveFile{Opts: dialog.SaveFileOptions{
|
||||||
Title: input.Title,
|
Title: input.Title,
|
||||||
Directory: input.Directory,
|
Directory: input.Directory,
|
||||||
Filename: input.Filename,
|
Filename: input.Filename,
|
||||||
|
|
@ -62,7 +62,7 @@ func (s *Subsystem) dialogSaveFile(_ context.Context, _ *mcp.CallToolRequest, in
|
||||||
}
|
}
|
||||||
path, ok := result.(string)
|
path, ok := result.(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, DialogSaveFileOutput{}, coreerr.E("mcp.dialogSaveFile", "unexpected result type", nil)
|
return nil, DialogSaveFileOutput{}, fmt.Errorf("unexpected result type from save file dialog")
|
||||||
}
|
}
|
||||||
return nil, DialogSaveFileOutput{Path: path}, 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) {
|
func (s *Subsystem) dialogOpenDirectory(_ context.Context, _ *mcp.CallToolRequest, input DialogOpenDirectoryInput) (*mcp.CallToolResult, DialogOpenDirectoryOutput, error) {
|
||||||
result, _, err := s.core.PERFORM(dialog.TaskOpenDirectory{Options: dialog.OpenDirectoryOptions{
|
result, _, err := s.core.PERFORM(dialog.TaskOpenDirectory{Opts: dialog.OpenDirectoryOptions{
|
||||||
Title: input.Title,
|
Title: input.Title,
|
||||||
Directory: input.Directory,
|
Directory: input.Directory,
|
||||||
}})
|
}})
|
||||||
|
|
@ -87,7 +87,7 @@ func (s *Subsystem) dialogOpenDirectory(_ context.Context, _ *mcp.CallToolReques
|
||||||
}
|
}
|
||||||
path, ok := result.(string)
|
path, ok := result.(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, DialogOpenDirectoryOutput{}, coreerr.E("mcp.dialogOpenDirectory", "unexpected result type", nil)
|
return nil, DialogOpenDirectoryOutput{}, fmt.Errorf("unexpected result type from open directory dialog")
|
||||||
}
|
}
|
||||||
return nil, DialogOpenDirectoryOutput{Path: path}, 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) {
|
func (s *Subsystem) dialogConfirm(_ context.Context, _ *mcp.CallToolRequest, input DialogConfirmInput) (*mcp.CallToolResult, DialogConfirmOutput, error) {
|
||||||
result, _, err := s.core.PERFORM(dialog.TaskMessageDialog{Options: dialog.MessageDialogOptions{
|
result, _, err := s.core.PERFORM(dialog.TaskMessageDialog{Opts: dialog.MessageDialogOptions{
|
||||||
Type: dialog.DialogQuestion,
|
Type: dialog.DialogQuestion,
|
||||||
Title: input.Title,
|
Title: input.Title,
|
||||||
Message: input.Message,
|
Message: input.Message,
|
||||||
|
|
@ -115,7 +115,7 @@ func (s *Subsystem) dialogConfirm(_ context.Context, _ *mcp.CallToolRequest, inp
|
||||||
}
|
}
|
||||||
button, ok := result.(string)
|
button, ok := result.(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, DialogConfirmOutput{}, coreerr.E("mcp.dialogConfirm", "unexpected result type", nil)
|
return nil, DialogConfirmOutput{}, fmt.Errorf("unexpected result type from confirm dialog")
|
||||||
}
|
}
|
||||||
return nil, DialogConfirmOutput{Button: button}, 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) {
|
func (s *Subsystem) dialogPrompt(_ context.Context, _ *mcp.CallToolRequest, input DialogPromptInput) (*mcp.CallToolResult, DialogPromptOutput, error) {
|
||||||
result, _, err := s.core.PERFORM(dialog.TaskMessageDialog{Options: dialog.MessageDialogOptions{
|
result, _, err := s.core.PERFORM(dialog.TaskMessageDialog{Opts: dialog.MessageDialogOptions{
|
||||||
Type: dialog.DialogInfo,
|
Type: dialog.DialogInfo,
|
||||||
Title: input.Title,
|
Title: input.Title,
|
||||||
Message: input.Message,
|
Message: input.Message,
|
||||||
|
|
@ -142,7 +142,7 @@ func (s *Subsystem) dialogPrompt(_ context.Context, _ *mcp.CallToolRequest, inpu
|
||||||
}
|
}
|
||||||
button, ok := result.(string)
|
button, ok := result.(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, DialogPromptOutput{}, coreerr.E("mcp.dialogPrompt", "unexpected result type", nil)
|
return nil, DialogPromptOutput{}, fmt.Errorf("unexpected result type from prompt dialog")
|
||||||
}
|
}
|
||||||
return nil, DialogPromptOutput{Button: button}, nil
|
return nil, DialogPromptOutput{Button: button}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
|
||||||
"forge.lthn.ai/core/gui/pkg/environment"
|
"forge.lthn.ai/core/gui/pkg/environment"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
@ -23,7 +23,7 @@ func (s *Subsystem) themeGet(_ context.Context, _ *mcp.CallToolRequest, _ ThemeG
|
||||||
}
|
}
|
||||||
theme, ok := result.(environment.ThemeInfo)
|
theme, ok := result.(environment.ThemeInfo)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, ThemeGetOutput{}, coreerr.E("mcp.themeGet", "unexpected result type", nil)
|
return nil, ThemeGetOutput{}, fmt.Errorf("unexpected result type from theme query")
|
||||||
}
|
}
|
||||||
return nil, ThemeGetOutput{Theme: theme}, nil
|
return nil, ThemeGetOutput{Theme: theme}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -42,14 +42,40 @@ func (s *Subsystem) themeSystem(_ context.Context, _ *mcp.CallToolRequest, _ The
|
||||||
}
|
}
|
||||||
info, ok := result.(environment.EnvironmentInfo)
|
info, ok := result.(environment.EnvironmentInfo)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, ThemeSystemOutput{}, coreerr.E("mcp.themeSystem", "unexpected result type", nil)
|
return nil, ThemeSystemOutput{}, fmt.Errorf("unexpected result type from environment info query")
|
||||||
}
|
}
|
||||||
return nil, ThemeSystemOutput{Info: info}, nil
|
return nil, ThemeSystemOutput{Info: info}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- theme_set ---
|
||||||
|
|
||||||
|
type ThemeSetInput struct {
|
||||||
|
Theme string `json:"theme"`
|
||||||
|
}
|
||||||
|
type ThemeSetOutput struct {
|
||||||
|
Theme environment.ThemeInfo `json:"theme"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) themeSet(_ context.Context, _ *mcp.CallToolRequest, input ThemeSetInput) (*mcp.CallToolResult, ThemeSetOutput, error) {
|
||||||
|
_, _, err := s.core.PERFORM(environment.TaskSetTheme{Theme: input.Theme})
|
||||||
|
if err != nil {
|
||||||
|
return nil, ThemeSetOutput{}, err
|
||||||
|
}
|
||||||
|
result, _, err := s.core.QUERY(environment.QueryTheme{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, ThemeSetOutput{}, err
|
||||||
|
}
|
||||||
|
theme, ok := result.(environment.ThemeInfo)
|
||||||
|
if !ok {
|
||||||
|
return nil, ThemeSetOutput{}, fmt.Errorf("unexpected result type from theme query")
|
||||||
|
}
|
||||||
|
return nil, ThemeSetOutput{Theme: theme}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// --- Registration ---
|
// --- Registration ---
|
||||||
|
|
||||||
func (s *Subsystem) registerEnvironmentTools(server *mcp.Server) {
|
func (s *Subsystem) registerEnvironmentTools(server *mcp.Server) {
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "theme_get", Description: "Get the current application theme"}, s.themeGet)
|
mcp.AddTool(server, &mcp.Tool{Name: "theme_get", Description: "Get the current application theme"}, s.themeGet)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "theme_system", Description: "Get system environment and theme information"}, s.themeSystem)
|
mcp.AddTool(server, &mcp.Tool{Name: "theme_system", Description: "Get system environment and theme information"}, s.themeSystem)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "theme_set", Description: "Set the application theme override"}, s.themeSet)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@ package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
"forge.lthn.ai/core/go/pkg/core"
|
||||||
|
"forge.lthn.ai/core/gui/pkg/screen"
|
||||||
"forge.lthn.ai/core/gui/pkg/window"
|
"forge.lthn.ai/core/gui/pkg/window"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
@ -57,7 +59,7 @@ func (s *Subsystem) layoutList(_ context.Context, _ *mcp.CallToolRequest, _ Layo
|
||||||
}
|
}
|
||||||
layouts, ok := result.([]window.LayoutInfo)
|
layouts, ok := result.([]window.LayoutInfo)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, LayoutListOutput{}, coreerr.E("mcp.layoutList", "unexpected result type", nil)
|
return nil, LayoutListOutput{}, fmt.Errorf("unexpected result type from layout list query")
|
||||||
}
|
}
|
||||||
return nil, LayoutListOutput{Layouts: layouts}, nil
|
return nil, LayoutListOutput{Layouts: layouts}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -95,7 +97,7 @@ func (s *Subsystem) layoutGet(_ context.Context, _ *mcp.CallToolRequest, input L
|
||||||
}
|
}
|
||||||
layout, ok := result.(*window.Layout)
|
layout, ok := result.(*window.Layout)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, LayoutGetOutput{}, coreerr.E("mcp.layoutGet", "unexpected result type", nil)
|
return nil, LayoutGetOutput{}, fmt.Errorf("unexpected result type from layout get query")
|
||||||
}
|
}
|
||||||
return nil, LayoutGetOutput{Layout: layout}, nil
|
return nil, LayoutGetOutput{Layout: layout}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -136,19 +138,145 @@ func (s *Subsystem) layoutSnap(_ context.Context, _ *mcp.CallToolRequest, input
|
||||||
return nil, LayoutSnapOutput{Success: true}, nil
|
return nil, LayoutSnapOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- layout_beside_editor ---
|
||||||
|
|
||||||
|
type LayoutBesideEditorInput struct {
|
||||||
|
Editor string `json:"editor,omitempty"`
|
||||||
|
Window string `json:"window,omitempty"`
|
||||||
|
}
|
||||||
|
type LayoutBesideEditorOutput struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) layoutBesideEditor(_ context.Context, _ *mcp.CallToolRequest, input LayoutBesideEditorInput) (*mcp.CallToolResult, LayoutBesideEditorOutput, error) {
|
||||||
|
_, _, err := s.core.PERFORM(window.TaskBesideEditor{Editor: input.Editor, Window: input.Window})
|
||||||
|
if err != nil {
|
||||||
|
return nil, LayoutBesideEditorOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, LayoutBesideEditorOutput{Success: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- layout_suggest ---
|
||||||
|
|
||||||
|
type LayoutSuggestInput struct {
|
||||||
|
WindowCount int `json:"windowCount,omitempty"`
|
||||||
|
ScreenWidth int `json:"screenWidth,omitempty"`
|
||||||
|
ScreenHeight int `json:"screenHeight,omitempty"`
|
||||||
|
}
|
||||||
|
type LayoutSuggestOutput struct {
|
||||||
|
Suggestion window.LayoutSuggestion `json:"suggestion"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) layoutSuggest(_ context.Context, _ *mcp.CallToolRequest, input LayoutSuggestInput) (*mcp.CallToolResult, LayoutSuggestOutput, error) {
|
||||||
|
windowCount := input.WindowCount
|
||||||
|
if windowCount <= 0 {
|
||||||
|
result, _, err := s.core.QUERY(window.QueryWindowList{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, LayoutSuggestOutput{}, err
|
||||||
|
}
|
||||||
|
windows, ok := result.([]window.WindowInfo)
|
||||||
|
if !ok {
|
||||||
|
return nil, LayoutSuggestOutput{}, fmt.Errorf("unexpected result type from window list query")
|
||||||
|
}
|
||||||
|
windowCount = len(windows)
|
||||||
|
}
|
||||||
|
screenW, screenH := input.ScreenWidth, input.ScreenHeight
|
||||||
|
if screenW <= 0 || screenH <= 0 {
|
||||||
|
screenW, screenH = primaryScreenSize(s.core)
|
||||||
|
}
|
||||||
|
result, handled, err := s.core.QUERY(window.QueryLayoutSuggestion{
|
||||||
|
WindowCount: windowCount,
|
||||||
|
ScreenWidth: screenW,
|
||||||
|
ScreenHeight: screenH,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, LayoutSuggestOutput{}, err
|
||||||
|
}
|
||||||
|
if !handled {
|
||||||
|
return nil, LayoutSuggestOutput{}, fmt.Errorf("window service not available")
|
||||||
|
}
|
||||||
|
suggestion, ok := result.(window.LayoutSuggestion)
|
||||||
|
if !ok {
|
||||||
|
return nil, LayoutSuggestOutput{}, fmt.Errorf("unexpected result type from layout suggestion query")
|
||||||
|
}
|
||||||
|
return nil, LayoutSuggestOutput{Suggestion: suggestion}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- screen_find_space ---
|
||||||
|
|
||||||
|
type ScreenFindSpaceInput struct {
|
||||||
|
Width int `json:"width,omitempty"`
|
||||||
|
Height int `json:"height,omitempty"`
|
||||||
|
}
|
||||||
|
type ScreenFindSpaceOutput struct {
|
||||||
|
Space window.SpaceInfo `json:"space"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) screenFindSpace(_ context.Context, _ *mcp.CallToolRequest, input ScreenFindSpaceInput) (*mcp.CallToolResult, ScreenFindSpaceOutput, error) {
|
||||||
|
screenW, screenH := primaryScreenSize(s.core)
|
||||||
|
if screenW <= 0 || screenH <= 0 {
|
||||||
|
screenW, screenH = 1920, 1080
|
||||||
|
}
|
||||||
|
result, handled, err := s.core.QUERY(window.QueryFindSpace{
|
||||||
|
Width: input.Width,
|
||||||
|
Height: input.Height,
|
||||||
|
ScreenWidth: screenW,
|
||||||
|
ScreenHeight: screenH,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, ScreenFindSpaceOutput{}, err
|
||||||
|
}
|
||||||
|
if !handled {
|
||||||
|
return nil, ScreenFindSpaceOutput{}, fmt.Errorf("window service not available")
|
||||||
|
}
|
||||||
|
space, ok := result.(window.SpaceInfo)
|
||||||
|
if !ok {
|
||||||
|
return nil, ScreenFindSpaceOutput{}, fmt.Errorf("unexpected result type from find space query")
|
||||||
|
}
|
||||||
|
if space.ScreenWidth == 0 {
|
||||||
|
space.ScreenWidth = screenW
|
||||||
|
}
|
||||||
|
if space.ScreenHeight == 0 {
|
||||||
|
space.ScreenHeight = screenH
|
||||||
|
}
|
||||||
|
return nil, ScreenFindSpaceOutput{Space: space}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- window_arrange_pair ---
|
||||||
|
|
||||||
|
type WindowArrangePairInput struct {
|
||||||
|
First string `json:"first"`
|
||||||
|
Second string `json:"second"`
|
||||||
|
}
|
||||||
|
type WindowArrangePairOutput struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) windowArrangePair(_ context.Context, _ *mcp.CallToolRequest, input WindowArrangePairInput) (*mcp.CallToolResult, WindowArrangePairOutput, error) {
|
||||||
|
_, _, err := s.core.PERFORM(window.TaskArrangePair{First: input.First, Second: input.Second})
|
||||||
|
if err != nil {
|
||||||
|
return nil, WindowArrangePairOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, WindowArrangePairOutput{Success: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// --- layout_stack ---
|
// --- layout_stack ---
|
||||||
|
|
||||||
type LayoutStackInput struct {
|
type LayoutStackInput struct {
|
||||||
Windows []string `json:"windows,omitempty"`
|
Windows []string `json:"windows,omitempty"`
|
||||||
OffsetX int `json:"offsetX"`
|
OffsetX int `json:"offsetX,omitempty"`
|
||||||
OffsetY int `json:"offsetY"`
|
OffsetY int `json:"offsetY,omitempty"`
|
||||||
}
|
}
|
||||||
type LayoutStackOutput struct {
|
type LayoutStackOutput struct {
|
||||||
Success bool `json:"success"`
|
Success bool `json:"success"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) layoutStack(_ context.Context, _ *mcp.CallToolRequest, input LayoutStackInput) (*mcp.CallToolResult, LayoutStackOutput, error) {
|
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})
|
_, _, err := s.core.PERFORM(window.TaskStackWindows{
|
||||||
|
Windows: input.Windows,
|
||||||
|
OffsetX: input.OffsetX,
|
||||||
|
OffsetY: input.OffsetY,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, LayoutStackOutput{}, err
|
return nil, LayoutStackOutput{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -166,7 +294,14 @@ type LayoutWorkflowOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) layoutWorkflow(_ context.Context, _ *mcp.CallToolRequest, input LayoutWorkflowInput) (*mcp.CallToolResult, LayoutWorkflowOutput, error) {
|
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})
|
workflow, ok := window.ParseWorkflowLayout(input.Workflow)
|
||||||
|
if !ok {
|
||||||
|
return nil, LayoutWorkflowOutput{}, fmt.Errorf("unknown workflow: %s", input.Workflow)
|
||||||
|
}
|
||||||
|
_, _, err := s.core.PERFORM(window.TaskApplyWorkflow{
|
||||||
|
Workflow: workflow,
|
||||||
|
Windows: input.Windows,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, LayoutWorkflowOutput{}, err
|
return nil, LayoutWorkflowOutput{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -182,7 +317,29 @@ 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_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_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_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, corner, or center"}, s.layoutSnap)
|
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_stack", Description: "Stack windows in a cascade pattern"}, s.layoutStack)
|
mcp.AddTool(server, &mcp.Tool{Name: "layout_beside_editor", Description: "Place a window beside a detected editor window"}, s.layoutBesideEditor)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "layout_workflow", Description: "Apply a preset workflow layout"}, s.layoutWorkflow)
|
mcp.AddTool(server, &mcp.Tool{Name: "layout_suggest", Description: "Suggest an optimal layout for the current screen"}, s.layoutSuggest)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "screen_find_space", Description: "Find an empty area for a new window"}, s.screenFindSpace)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "window_arrange_pair", Description: "Arrange two windows side-by-side"}, s.windowArrangePair)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "layout_stack", Description: "Cascade windows with an offset"}, s.layoutStack)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "layout_workflow", Description: "Apply a predefined workflow layout"}, s.layoutWorkflow)
|
||||||
|
}
|
||||||
|
|
||||||
|
func primaryScreenSize(c *core.Core) (int, int) {
|
||||||
|
result, handled, err := c.QUERY(screen.QueryPrimary{})
|
||||||
|
if err == nil && handled {
|
||||||
|
if scr, ok := result.(*screen.Screen); ok && scr != nil {
|
||||||
|
if scr.WorkArea.Width > 0 && scr.WorkArea.Height > 0 {
|
||||||
|
return scr.WorkArea.Width, scr.WorkArea.Height
|
||||||
|
}
|
||||||
|
if scr.Bounds.Width > 0 && scr.Bounds.Height > 0 {
|
||||||
|
return scr.Bounds.Width, scr.Bounds.Height
|
||||||
|
}
|
||||||
|
if scr.Size.Width > 0 && scr.Size.Height > 0 {
|
||||||
|
return scr.Size.Width, scr.Size.Height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 1920, 1080
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
|
||||||
"forge.lthn.ai/core/gui/pkg/notification"
|
"forge.lthn.ai/core/gui/pkg/notification"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"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) {
|
func (s *Subsystem) notificationShow(_ context.Context, _ *mcp.CallToolRequest, input NotificationShowInput) (*mcp.CallToolResult, NotificationShowOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(notification.TaskSend{Options: notification.NotificationOptions{
|
_, _, err := s.core.PERFORM(notification.TaskSend{Opts: notification.NotificationOptions{
|
||||||
Title: input.Title,
|
Title: input.Title,
|
||||||
Message: input.Message,
|
Message: input.Message,
|
||||||
Subtitle: input.Subtitle,
|
Subtitle: input.Subtitle,
|
||||||
|
|
@ -32,6 +32,31 @@ func (s *Subsystem) notificationShow(_ context.Context, _ *mcp.CallToolRequest,
|
||||||
return nil, NotificationShowOutput{Success: true}, nil
|
return nil, NotificationShowOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- notification_with_actions ---
|
||||||
|
|
||||||
|
type NotificationWithActionsInput struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Subtitle string `json:"subtitle,omitempty"`
|
||||||
|
Actions []notification.NotificationAction `json:"actions,omitempty"`
|
||||||
|
}
|
||||||
|
type NotificationWithActionsOutput struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) notificationWithActions(_ context.Context, _ *mcp.CallToolRequest, input NotificationWithActionsInput) (*mcp.CallToolResult, NotificationWithActionsOutput, error) {
|
||||||
|
_, _, err := s.core.PERFORM(notification.TaskSend{Opts: notification.NotificationOptions{
|
||||||
|
Title: input.Title,
|
||||||
|
Message: input.Message,
|
||||||
|
Subtitle: input.Subtitle,
|
||||||
|
Actions: input.Actions,
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
return nil, NotificationWithActionsOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, NotificationWithActionsOutput{Success: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// --- notification_permission_request ---
|
// --- notification_permission_request ---
|
||||||
|
|
||||||
type NotificationPermissionRequestInput struct{}
|
type NotificationPermissionRequestInput struct{}
|
||||||
|
|
@ -46,7 +71,7 @@ func (s *Subsystem) notificationPermissionRequest(_ context.Context, _ *mcp.Call
|
||||||
}
|
}
|
||||||
granted, ok := result.(bool)
|
granted, ok := result.(bool)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, NotificationPermissionRequestOutput{}, coreerr.E("mcp.notificationPermissionRequest", "unexpected result type from notification permission request", nil)
|
return nil, NotificationPermissionRequestOutput{}, fmt.Errorf("unexpected result type from notification permission request")
|
||||||
}
|
}
|
||||||
return nil, NotificationPermissionRequestOutput{Granted: granted}, nil
|
return nil, NotificationPermissionRequestOutput{Granted: granted}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -65,15 +90,65 @@ func (s *Subsystem) notificationPermissionCheck(_ context.Context, _ *mcp.CallTo
|
||||||
}
|
}
|
||||||
status, ok := result.(notification.PermissionStatus)
|
status, ok := result.(notification.PermissionStatus)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, NotificationPermissionCheckOutput{}, coreerr.E("mcp.notificationPermissionCheck", "unexpected result type from notification permission check", nil)
|
return nil, NotificationPermissionCheckOutput{}, fmt.Errorf("unexpected result type from notification permission check")
|
||||||
}
|
}
|
||||||
return nil, NotificationPermissionCheckOutput{Granted: status.Granted}, nil
|
return nil, NotificationPermissionCheckOutput{Granted: status.Granted}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- notification_clear ---
|
||||||
|
|
||||||
|
type NotificationClearInput struct{}
|
||||||
|
type NotificationClearOutput struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) notificationClear(_ context.Context, _ *mcp.CallToolRequest, _ NotificationClearInput) (*mcp.CallToolResult, NotificationClearOutput, error) {
|
||||||
|
_, _, err := s.core.PERFORM(notification.TaskClear{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, NotificationClearOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, NotificationClearOutput{Success: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- dialog_message ---
|
||||||
|
|
||||||
|
type DialogMessageInput struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Kind string `json:"kind,omitempty"`
|
||||||
|
}
|
||||||
|
type DialogMessageOutput struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) dialogMessage(_ context.Context, _ *mcp.CallToolRequest, input DialogMessageInput) (*mcp.CallToolResult, DialogMessageOutput, error) {
|
||||||
|
var severity notification.NotificationSeverity
|
||||||
|
switch input.Kind {
|
||||||
|
case "warning":
|
||||||
|
severity = notification.SeverityWarning
|
||||||
|
case "error":
|
||||||
|
severity = notification.SeverityError
|
||||||
|
default:
|
||||||
|
severity = notification.SeverityInfo
|
||||||
|
}
|
||||||
|
_, _, err := s.core.PERFORM(notification.TaskSend{Opts: notification.NotificationOptions{
|
||||||
|
Title: input.Title,
|
||||||
|
Message: input.Message,
|
||||||
|
Severity: severity,
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
return nil, DialogMessageOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, DialogMessageOutput{Success: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// --- Registration ---
|
// --- Registration ---
|
||||||
|
|
||||||
func (s *Subsystem) registerNotificationTools(server *mcp.Server) {
|
func (s *Subsystem) registerNotificationTools(server *mcp.Server) {
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "notification_show", Description: "Show a desktop notification"}, s.notificationShow)
|
mcp.AddTool(server, &mcp.Tool{Name: "notification_show", Description: "Show a desktop notification"}, s.notificationShow)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "notification_with_actions", Description: "Show a desktop notification with actions"}, s.notificationWithActions)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "notification_permission_request", Description: "Request notification permission"}, s.notificationPermissionRequest)
|
mcp.AddTool(server, &mcp.Tool{Name: "notification_permission_request", Description: "Request notification permission"}, s.notificationPermissionRequest)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "notification_permission_check", Description: "Check notification permission status"}, s.notificationPermissionCheck)
|
mcp.AddTool(server, &mcp.Tool{Name: "notification_permission_check", Description: "Check notification permission status"}, s.notificationPermissionCheck)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "notification_clear", Description: "Clear notifications when supported"}, s.notificationClear)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "dialog_message", Description: "Show a message dialog using the notification pipeline"}, s.dialogMessage)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,11 @@ package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
"forge.lthn.ai/core/go/pkg/core"
|
||||||
|
"forge.lthn.ai/core/gui/pkg/display"
|
||||||
"forge.lthn.ai/core/gui/pkg/screen"
|
"forge.lthn.ai/core/gui/pkg/screen"
|
||||||
"forge.lthn.ai/core/gui/pkg/window"
|
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -24,7 +25,7 @@ func (s *Subsystem) screenList(_ context.Context, _ *mcp.CallToolRequest, _ Scre
|
||||||
}
|
}
|
||||||
screens, ok := result.([]screen.Screen)
|
screens, ok := result.([]screen.Screen)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, ScreenListOutput{}, coreerr.E("mcp.screenList", "unexpected result type", nil)
|
return nil, ScreenListOutput{}, fmt.Errorf("unexpected result type from screen list query")
|
||||||
}
|
}
|
||||||
return nil, ScreenListOutput{Screens: screens}, nil
|
return nil, ScreenListOutput{Screens: screens}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -45,7 +46,7 @@ func (s *Subsystem) screenGet(_ context.Context, _ *mcp.CallToolRequest, input S
|
||||||
}
|
}
|
||||||
scr, ok := result.(*screen.Screen)
|
scr, ok := result.(*screen.Screen)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, ScreenGetOutput{}, coreerr.E("mcp.screenGet", "unexpected result type", nil)
|
return nil, ScreenGetOutput{}, fmt.Errorf("unexpected result type from screen get query")
|
||||||
}
|
}
|
||||||
return nil, ScreenGetOutput{Screen: scr}, nil
|
return nil, ScreenGetOutput{Screen: scr}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -64,7 +65,7 @@ func (s *Subsystem) screenPrimary(_ context.Context, _ *mcp.CallToolRequest, _ S
|
||||||
}
|
}
|
||||||
scr, ok := result.(*screen.Screen)
|
scr, ok := result.(*screen.Screen)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, ScreenPrimaryOutput{}, coreerr.E("mcp.screenPrimary", "unexpected result type", nil)
|
return nil, ScreenPrimaryOutput{}, fmt.Errorf("unexpected result type from screen primary query")
|
||||||
}
|
}
|
||||||
return nil, ScreenPrimaryOutput{Screen: scr}, nil
|
return nil, ScreenPrimaryOutput{Screen: scr}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -86,7 +87,7 @@ func (s *Subsystem) screenAtPoint(_ context.Context, _ *mcp.CallToolRequest, inp
|
||||||
}
|
}
|
||||||
scr, ok := result.(*screen.Screen)
|
scr, ok := result.(*screen.Screen)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, ScreenAtPointOutput{}, coreerr.E("mcp.screenAtPoint", "unexpected result type", nil)
|
return nil, ScreenAtPointOutput{}, fmt.Errorf("unexpected result type from screen at point query")
|
||||||
}
|
}
|
||||||
return nil, ScreenAtPointOutput{Screen: scr}, nil
|
return nil, ScreenAtPointOutput{Screen: scr}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -105,36 +106,35 @@ func (s *Subsystem) screenWorkAreas(_ context.Context, _ *mcp.CallToolRequest, _
|
||||||
}
|
}
|
||||||
areas, ok := result.([]screen.Rect)
|
areas, ok := result.([]screen.Rect)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, ScreenWorkAreasOutput{}, coreerr.E("mcp.screenWorkAreas", "unexpected result type", nil)
|
return nil, ScreenWorkAreasOutput{}, fmt.Errorf("unexpected result type from screen work areas query")
|
||||||
}
|
}
|
||||||
return nil, ScreenWorkAreasOutput{WorkAreas: areas}, nil
|
return nil, ScreenWorkAreasOutput{WorkAreas: areas}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- screen_work_area ---
|
||||||
|
|
||||||
|
func (s *Subsystem) screenWorkArea(ctx context.Context, req *mcp.CallToolRequest, input ScreenWorkAreasInput) (*mcp.CallToolResult, ScreenWorkAreasOutput, error) {
|
||||||
|
return s.screenWorkAreas(ctx, req, input)
|
||||||
|
}
|
||||||
|
|
||||||
// --- screen_for_window ---
|
// --- screen_for_window ---
|
||||||
|
|
||||||
type ScreenForWindowInput struct {
|
type ScreenForWindowInput struct {
|
||||||
Name string `json:"name"`
|
Window string `json:"window"`
|
||||||
}
|
}
|
||||||
type ScreenForWindowOutput struct {
|
type ScreenForWindowOutput struct {
|
||||||
Screen *screen.Screen `json:"screen"`
|
Screen *screen.Screen `json:"screen"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) screenForWindow(_ context.Context, _ *mcp.CallToolRequest, input ScreenForWindowInput) (*mcp.CallToolResult, ScreenForWindowOutput, error) {
|
func (s *Subsystem) screenForWindow(_ context.Context, _ *mcp.CallToolRequest, input ScreenForWindowInput) (*mcp.CallToolResult, ScreenForWindowOutput, error) {
|
||||||
result, _, err := s.core.QUERY(window.QueryWindowByName{Name: input.Name})
|
svc, err := core.ServiceFor[*display.Service](s.core, "display")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, ScreenForWindowOutput{}, err
|
return nil, ScreenForWindowOutput{}, err
|
||||||
}
|
}
|
||||||
info, _ := result.(*window.WindowInfo)
|
scr, err := svc.GetScreenForWindow(input.Window)
|
||||||
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 {
|
if err != nil {
|
||||||
return nil, ScreenForWindowOutput{}, err
|
return nil, ScreenForWindowOutput{}, err
|
||||||
}
|
}
|
||||||
scr, _ := screenResult.(*screen.Screen)
|
|
||||||
return nil, ScreenForWindowOutput{Screen: scr}, nil
|
return nil, ScreenForWindowOutput{Screen: scr}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -146,5 +146,6 @@ 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_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_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_work_areas", Description: "Get work areas for all screens"}, s.screenWorkAreas)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "screen_work_area", Description: "Alias for screen_work_areas"}, s.screenWorkArea)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "screen_for_window", Description: "Get the screen containing a window"}, s.screenForWindow)
|
mcp.AddTool(server, &mcp.Tool{Name: "screen_for_window", Description: "Get the screen containing a window"}, s.screenForWindow)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
|
||||||
"forge.lthn.ai/core/gui/pkg/systray"
|
"forge.lthn.ai/core/gui/pkg/systray"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
@ -36,8 +36,10 @@ type TraySetTooltipOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) traySetTooltip(_ context.Context, _ *mcp.CallToolRequest, input TraySetTooltipInput) (*mcp.CallToolResult, TraySetTooltipOutput, error) {
|
func (s *Subsystem) traySetTooltip(_ context.Context, _ *mcp.CallToolRequest, input TraySetTooltipInput) (*mcp.CallToolResult, TraySetTooltipOutput, error) {
|
||||||
// Tooltip is set via the tray menu items; for now this is a no-op placeholder
|
_, _, err := s.core.PERFORM(systray.TaskSetTooltip{Tooltip: input.Tooltip})
|
||||||
_ = input.Tooltip
|
if err != nil {
|
||||||
|
return nil, TraySetTooltipOutput{}, err
|
||||||
|
}
|
||||||
return nil, TraySetTooltipOutput{Success: true}, nil
|
return nil, TraySetTooltipOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -51,8 +53,10 @@ type TraySetLabelOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) traySetLabel(_ context.Context, _ *mcp.CallToolRequest, input TraySetLabelInput) (*mcp.CallToolResult, TraySetLabelOutput, error) {
|
func (s *Subsystem) traySetLabel(_ context.Context, _ *mcp.CallToolRequest, input TraySetLabelInput) (*mcp.CallToolResult, TraySetLabelOutput, error) {
|
||||||
// Label is part of the tray configuration; placeholder for now
|
_, _, err := s.core.PERFORM(systray.TaskSetLabel{Label: input.Label})
|
||||||
_ = input.Label
|
if err != nil {
|
||||||
|
return nil, TraySetLabelOutput{}, err
|
||||||
|
}
|
||||||
return nil, TraySetLabelOutput{Success: true}, nil
|
return nil, TraySetLabelOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,11 +74,29 @@ func (s *Subsystem) trayInfo(_ context.Context, _ *mcp.CallToolRequest, _ TrayIn
|
||||||
}
|
}
|
||||||
config, ok := result.(map[string]any)
|
config, ok := result.(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, TrayInfoOutput{}, coreerr.E("mcp.trayInfo", "unexpected result type", nil)
|
return nil, TrayInfoOutput{}, fmt.Errorf("unexpected result type from tray config query")
|
||||||
}
|
}
|
||||||
return nil, TrayInfoOutput{Config: config}, nil
|
return nil, TrayInfoOutput{Config: config}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- tray_show_message ---
|
||||||
|
|
||||||
|
type TrayShowMessageInput struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
type TrayShowMessageOutput struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) trayShowMessage(_ context.Context, _ *mcp.CallToolRequest, input TrayShowMessageInput) (*mcp.CallToolResult, TrayShowMessageOutput, error) {
|
||||||
|
_, _, err := s.core.PERFORM(systray.TaskShowMessage{Title: input.Title, Message: input.Message})
|
||||||
|
if err != nil {
|
||||||
|
return nil, TrayShowMessageOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, TrayShowMessageOutput{Success: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// --- Registration ---
|
// --- Registration ---
|
||||||
|
|
||||||
func (s *Subsystem) registerTrayTools(server *mcp.Server) {
|
func (s *Subsystem) registerTrayTools(server *mcp.Server) {
|
||||||
|
|
@ -82,4 +104,5 @@ func (s *Subsystem) registerTrayTools(server *mcp.Server) {
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "tray_set_tooltip", Description: "Set the system tray tooltip"}, s.traySetTooltip)
|
mcp.AddTool(server, &mcp.Tool{Name: "tray_set_tooltip", Description: "Set the system tray tooltip"}, s.traySetTooltip)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "tray_set_label", Description: "Set the system tray label"}, s.traySetLabel)
|
mcp.AddTool(server, &mcp.Tool{Name: "tray_set_label", Description: "Set the system tray label"}, s.traySetLabel)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "tray_info", Description: "Get system tray configuration"}, s.trayInfo)
|
mcp.AddTool(server, &mcp.Tool{Name: "tray_info", Description: "Get system tray configuration"}, s.trayInfo)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "tray_show_message", Description: "Show a tray message or notification"}, s.trayShowMessage)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
|
||||||
"forge.lthn.ai/core/gui/pkg/webview"
|
"forge.lthn.ai/core/gui/pkg/webview"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
@ -105,11 +105,35 @@ func (s *Subsystem) webviewScreenshot(_ context.Context, _ *mcp.CallToolRequest,
|
||||||
}
|
}
|
||||||
sr, ok := result.(webview.ScreenshotResult)
|
sr, ok := result.(webview.ScreenshotResult)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, WebviewScreenshotOutput{}, coreerr.E("mcp.webviewScreenshot", "unexpected result type", nil)
|
return nil, WebviewScreenshotOutput{}, fmt.Errorf("unexpected result type from webview screenshot")
|
||||||
}
|
}
|
||||||
return nil, WebviewScreenshotOutput{Base64: sr.Base64, MimeType: sr.MimeType}, nil
|
return nil, WebviewScreenshotOutput{Base64: sr.Base64, MimeType: sr.MimeType}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- webview_screenshot_element ---
|
||||||
|
|
||||||
|
type WebviewScreenshotElementInput struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
Selector string `json:"selector"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebviewScreenshotElementOutput struct {
|
||||||
|
Base64 string `json:"base64"`
|
||||||
|
MimeType string `json:"mimeType"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) webviewScreenshotElement(_ context.Context, _ *mcp.CallToolRequest, input WebviewScreenshotElementInput) (*mcp.CallToolResult, WebviewScreenshotElementOutput, error) {
|
||||||
|
result, _, err := s.core.PERFORM(webview.TaskScreenshotElement{Window: input.Window, Selector: input.Selector})
|
||||||
|
if err != nil {
|
||||||
|
return nil, WebviewScreenshotElementOutput{}, err
|
||||||
|
}
|
||||||
|
sr, ok := result.(webview.ScreenshotResult)
|
||||||
|
if !ok {
|
||||||
|
return nil, WebviewScreenshotElementOutput{}, fmt.Errorf("unexpected result type from webview element screenshot")
|
||||||
|
}
|
||||||
|
return nil, WebviewScreenshotElementOutput{Base64: sr.Base64, MimeType: sr.MimeType}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// --- webview_scroll ---
|
// --- webview_scroll ---
|
||||||
|
|
||||||
type WebviewScrollInput struct {
|
type WebviewScrollInput struct {
|
||||||
|
|
@ -248,7 +272,7 @@ func (s *Subsystem) webviewConsole(_ context.Context, _ *mcp.CallToolRequest, in
|
||||||
}
|
}
|
||||||
msgs, ok := result.([]webview.ConsoleMessage)
|
msgs, ok := result.([]webview.ConsoleMessage)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, WebviewConsoleOutput{}, coreerr.E("mcp.webviewConsole", "unexpected result type", nil)
|
return nil, WebviewConsoleOutput{}, fmt.Errorf("unexpected result type from webview console query")
|
||||||
}
|
}
|
||||||
return nil, WebviewConsoleOutput{Messages: msgs}, nil
|
return nil, WebviewConsoleOutput{Messages: msgs}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -271,6 +295,63 @@ func (s *Subsystem) webviewConsoleClear(_ context.Context, _ *mcp.CallToolReques
|
||||||
return nil, WebviewConsoleClearOutput{Success: true}, nil
|
return nil, WebviewConsoleClearOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- webview_errors ---
|
||||||
|
|
||||||
|
type WebviewErrorsInput struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebviewErrorsOutput struct {
|
||||||
|
Errors []webview.ExceptionInfo `json:"errors"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) webviewErrors(_ context.Context, _ *mcp.CallToolRequest, input WebviewErrorsInput) (*mcp.CallToolResult, WebviewErrorsOutput, error) {
|
||||||
|
result, _, err := s.core.QUERY(webview.QueryExceptions{Window: input.Window, Limit: input.Limit})
|
||||||
|
if err != nil {
|
||||||
|
return nil, WebviewErrorsOutput{}, err
|
||||||
|
}
|
||||||
|
errors, ok := result.([]webview.ExceptionInfo)
|
||||||
|
if !ok {
|
||||||
|
return nil, WebviewErrorsOutput{}, fmt.Errorf("unexpected result type from webview errors query")
|
||||||
|
}
|
||||||
|
return nil, WebviewErrorsOutput{Errors: errors}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- webview_devtools_open ---
|
||||||
|
|
||||||
|
type WebviewDevToolsOpenInput struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
}
|
||||||
|
type WebviewDevToolsOpenOutput struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) webviewDevToolsOpen(_ context.Context, _ *mcp.CallToolRequest, input WebviewDevToolsOpenInput) (*mcp.CallToolResult, WebviewDevToolsOpenOutput, error) {
|
||||||
|
_, _, err := s.core.PERFORM(webview.TaskOpenDevTools{Window: input.Window})
|
||||||
|
if err != nil {
|
||||||
|
return nil, WebviewDevToolsOpenOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, WebviewDevToolsOpenOutput{Success: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- webview_devtools_close ---
|
||||||
|
|
||||||
|
type WebviewDevToolsCloseInput struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
}
|
||||||
|
type WebviewDevToolsCloseOutput struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) webviewDevToolsClose(_ context.Context, _ *mcp.CallToolRequest, input WebviewDevToolsCloseInput) (*mcp.CallToolResult, WebviewDevToolsCloseOutput, error) {
|
||||||
|
_, _, err := s.core.PERFORM(webview.TaskCloseDevTools{Window: input.Window})
|
||||||
|
if err != nil {
|
||||||
|
return nil, WebviewDevToolsCloseOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, WebviewDevToolsCloseOutput{Success: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// --- webview_query ---
|
// --- webview_query ---
|
||||||
|
|
||||||
type WebviewQueryInput struct {
|
type WebviewQueryInput struct {
|
||||||
|
|
@ -289,11 +370,17 @@ func (s *Subsystem) webviewQuery(_ context.Context, _ *mcp.CallToolRequest, inpu
|
||||||
}
|
}
|
||||||
el, ok := result.(*webview.ElementInfo)
|
el, ok := result.(*webview.ElementInfo)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, WebviewQueryOutput{}, coreerr.E("mcp.webviewQuery", "unexpected result type", nil)
|
return nil, WebviewQueryOutput{}, fmt.Errorf("unexpected result type from webview query")
|
||||||
}
|
}
|
||||||
return nil, WebviewQueryOutput{Element: el}, nil
|
return nil, WebviewQueryOutput{Element: el}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- webview_element_info ---
|
||||||
|
|
||||||
|
func (s *Subsystem) webviewElementInfo(_ context.Context, _ *mcp.CallToolRequest, input WebviewQueryInput) (*mcp.CallToolResult, WebviewQueryOutput, error) {
|
||||||
|
return s.webviewQuery(nil, nil, input)
|
||||||
|
}
|
||||||
|
|
||||||
// --- webview_query_all ---
|
// --- webview_query_all ---
|
||||||
|
|
||||||
type WebviewQueryAllInput struct {
|
type WebviewQueryAllInput struct {
|
||||||
|
|
@ -312,7 +399,7 @@ func (s *Subsystem) webviewQueryAll(_ context.Context, _ *mcp.CallToolRequest, i
|
||||||
}
|
}
|
||||||
els, ok := result.([]*webview.ElementInfo)
|
els, ok := result.([]*webview.ElementInfo)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, WebviewQueryAllOutput{}, coreerr.E("mcp.webviewQueryAll", "unexpected result type", nil)
|
return nil, WebviewQueryAllOutput{}, fmt.Errorf("unexpected result type from webview query all")
|
||||||
}
|
}
|
||||||
return nil, WebviewQueryAllOutput{Elements: els}, nil
|
return nil, WebviewQueryAllOutput{Elements: els}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -335,11 +422,204 @@ func (s *Subsystem) webviewDOMTree(_ context.Context, _ *mcp.CallToolRequest, in
|
||||||
}
|
}
|
||||||
html, ok := result.(string)
|
html, ok := result.(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, WebviewDOMTreeOutput{}, coreerr.E("mcp.webviewDOMTree", "unexpected result type", nil)
|
return nil, WebviewDOMTreeOutput{}, fmt.Errorf("unexpected result type from webview DOM tree query")
|
||||||
}
|
}
|
||||||
return nil, WebviewDOMTreeOutput{HTML: html}, nil
|
return nil, WebviewDOMTreeOutput{HTML: html}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- webview_source ---
|
||||||
|
|
||||||
|
func (s *Subsystem) webviewSource(_ context.Context, _ *mcp.CallToolRequest, input WebviewDOMTreeInput) (*mcp.CallToolResult, WebviewDOMTreeOutput, error) {
|
||||||
|
return s.webviewDOMTree(nil, nil, input)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- webview_computed_style ---
|
||||||
|
|
||||||
|
type WebviewComputedStyleInput struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
Selector string `json:"selector"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebviewComputedStyleOutput struct {
|
||||||
|
Style map[string]string `json:"style"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) webviewComputedStyle(_ context.Context, _ *mcp.CallToolRequest, input WebviewComputedStyleInput) (*mcp.CallToolResult, WebviewComputedStyleOutput, error) {
|
||||||
|
result, _, err := s.core.QUERY(webview.QueryComputedStyle{Window: input.Window, Selector: input.Selector})
|
||||||
|
if err != nil {
|
||||||
|
return nil, WebviewComputedStyleOutput{}, err
|
||||||
|
}
|
||||||
|
style, ok := result.(map[string]string)
|
||||||
|
if !ok {
|
||||||
|
return nil, WebviewComputedStyleOutput{}, fmt.Errorf("unexpected result type from webview computed style query")
|
||||||
|
}
|
||||||
|
return nil, WebviewComputedStyleOutput{Style: style}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- webview_performance ---
|
||||||
|
|
||||||
|
type WebviewPerformanceInput struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebviewPerformanceOutput struct {
|
||||||
|
Metrics webview.PerformanceMetrics `json:"metrics"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) webviewPerformance(_ context.Context, _ *mcp.CallToolRequest, input WebviewPerformanceInput) (*mcp.CallToolResult, WebviewPerformanceOutput, error) {
|
||||||
|
result, _, err := s.core.QUERY(webview.QueryPerformance{Window: input.Window})
|
||||||
|
if err != nil {
|
||||||
|
return nil, WebviewPerformanceOutput{}, err
|
||||||
|
}
|
||||||
|
metrics, ok := result.(webview.PerformanceMetrics)
|
||||||
|
if !ok {
|
||||||
|
return nil, WebviewPerformanceOutput{}, fmt.Errorf("unexpected result type from webview performance query")
|
||||||
|
}
|
||||||
|
return nil, WebviewPerformanceOutput{Metrics: metrics}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- webview_resources ---
|
||||||
|
|
||||||
|
type WebviewResourcesInput struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebviewResourcesOutput struct {
|
||||||
|
Resources []webview.ResourceEntry `json:"resources"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) webviewResources(_ context.Context, _ *mcp.CallToolRequest, input WebviewResourcesInput) (*mcp.CallToolResult, WebviewResourcesOutput, error) {
|
||||||
|
result, _, err := s.core.QUERY(webview.QueryResources{Window: input.Window})
|
||||||
|
if err != nil {
|
||||||
|
return nil, WebviewResourcesOutput{}, err
|
||||||
|
}
|
||||||
|
resources, ok := result.([]webview.ResourceEntry)
|
||||||
|
if !ok {
|
||||||
|
return nil, WebviewResourcesOutput{}, fmt.Errorf("unexpected result type from webview resources query")
|
||||||
|
}
|
||||||
|
return nil, WebviewResourcesOutput{Resources: resources}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- webview_network ---
|
||||||
|
|
||||||
|
type WebviewNetworkInput struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebviewNetworkOutput struct {
|
||||||
|
Requests []webview.NetworkEntry `json:"requests"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) webviewNetwork(_ context.Context, _ *mcp.CallToolRequest, input WebviewNetworkInput) (*mcp.CallToolResult, WebviewNetworkOutput, error) {
|
||||||
|
result, _, err := s.core.QUERY(webview.QueryNetwork{Window: input.Window, Limit: input.Limit})
|
||||||
|
if err != nil {
|
||||||
|
return nil, WebviewNetworkOutput{}, err
|
||||||
|
}
|
||||||
|
requests, ok := result.([]webview.NetworkEntry)
|
||||||
|
if !ok {
|
||||||
|
return nil, WebviewNetworkOutput{}, fmt.Errorf("unexpected result type from webview network query")
|
||||||
|
}
|
||||||
|
return nil, WebviewNetworkOutput{Requests: requests}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- webview_network_inject ---
|
||||||
|
|
||||||
|
type WebviewNetworkInjectInput struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebviewNetworkInjectOutput struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) webviewNetworkInject(_ context.Context, _ *mcp.CallToolRequest, input WebviewNetworkInjectInput) (*mcp.CallToolResult, WebviewNetworkInjectOutput, error) {
|
||||||
|
_, _, err := s.core.PERFORM(webview.TaskInjectNetworkLogging{Window: input.Window})
|
||||||
|
if err != nil {
|
||||||
|
return nil, WebviewNetworkInjectOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, WebviewNetworkInjectOutput{Success: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- webview_network_clear ---
|
||||||
|
|
||||||
|
type WebviewNetworkClearInput struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebviewNetworkClearOutput struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) webviewNetworkClear(_ context.Context, _ *mcp.CallToolRequest, input WebviewNetworkClearInput) (*mcp.CallToolResult, WebviewNetworkClearOutput, error) {
|
||||||
|
_, _, err := s.core.PERFORM(webview.TaskClearNetworkLog{Window: input.Window})
|
||||||
|
if err != nil {
|
||||||
|
return nil, WebviewNetworkClearOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, WebviewNetworkClearOutput{Success: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- webview_highlight ---
|
||||||
|
|
||||||
|
type WebviewHighlightInput struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
Selector string `json:"selector"`
|
||||||
|
Colour string `json:"colour,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebviewHighlightOutput struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) webviewHighlight(_ context.Context, _ *mcp.CallToolRequest, input WebviewHighlightInput) (*mcp.CallToolResult, WebviewHighlightOutput, error) {
|
||||||
|
_, _, err := s.core.PERFORM(webview.TaskHighlight{Window: input.Window, Selector: input.Selector, Colour: input.Colour})
|
||||||
|
if err != nil {
|
||||||
|
return nil, WebviewHighlightOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, WebviewHighlightOutput{Success: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- webview_print ---
|
||||||
|
|
||||||
|
type WebviewPrintInput struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebviewPrintOutput struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) webviewPrint(_ context.Context, _ *mcp.CallToolRequest, input WebviewPrintInput) (*mcp.CallToolResult, WebviewPrintOutput, error) {
|
||||||
|
_, _, err := s.core.PERFORM(webview.TaskPrint{Window: input.Window})
|
||||||
|
if err != nil {
|
||||||
|
return nil, WebviewPrintOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, WebviewPrintOutput{Success: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- webview_pdf ---
|
||||||
|
|
||||||
|
type WebviewPDFInput struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type WebviewPDFOutput struct {
|
||||||
|
Base64 string `json:"base64"`
|
||||||
|
MimeType string `json:"mimeType"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) webviewPDF(_ context.Context, _ *mcp.CallToolRequest, input WebviewPDFInput) (*mcp.CallToolResult, WebviewPDFOutput, error) {
|
||||||
|
result, _, err := s.core.PERFORM(webview.TaskExportPDF{Window: input.Window})
|
||||||
|
if err != nil {
|
||||||
|
return nil, WebviewPDFOutput{}, err
|
||||||
|
}
|
||||||
|
pdf, ok := result.(webview.PDFResult)
|
||||||
|
if !ok {
|
||||||
|
return nil, WebviewPDFOutput{}, fmt.Errorf("unexpected result type from webview pdf task")
|
||||||
|
}
|
||||||
|
return nil, WebviewPDFOutput{Base64: pdf.Base64, MimeType: pdf.MimeType}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// --- webview_url ---
|
// --- webview_url ---
|
||||||
|
|
||||||
type WebviewURLInput struct {
|
type WebviewURLInput struct {
|
||||||
|
|
@ -357,7 +637,7 @@ func (s *Subsystem) webviewURL(_ context.Context, _ *mcp.CallToolRequest, input
|
||||||
}
|
}
|
||||||
url, ok := result.(string)
|
url, ok := result.(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, WebviewURLOutput{}, coreerr.E("mcp.webviewURL", "unexpected result type", nil)
|
return nil, WebviewURLOutput{}, fmt.Errorf("unexpected result type from webview URL query")
|
||||||
}
|
}
|
||||||
return nil, WebviewURLOutput{URL: url}, nil
|
return nil, WebviewURLOutput{URL: url}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -379,7 +659,7 @@ func (s *Subsystem) webviewTitle(_ context.Context, _ *mcp.CallToolRequest, inpu
|
||||||
}
|
}
|
||||||
title, ok := result.(string)
|
title, ok := result.(string)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, WebviewTitleOutput{}, coreerr.E("mcp.webviewTitle", "unexpected result type", nil)
|
return nil, WebviewTitleOutput{}, fmt.Errorf("unexpected result type from webview title query")
|
||||||
}
|
}
|
||||||
return nil, WebviewTitleOutput{Title: title}, nil
|
return nil, WebviewTitleOutput{Title: title}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -392,6 +672,7 @@ func (s *Subsystem) registerWebviewTools(server *mcp.Server) {
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "webview_type", Description: "Type text into an element in a webview"}, s.webviewType)
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_type", Description: "Type text into an element in a webview"}, s.webviewType)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "webview_navigate", Description: "Navigate a webview to a URL"}, s.webviewNavigate)
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_navigate", Description: "Navigate a webview to a URL"}, s.webviewNavigate)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "webview_screenshot", Description: "Capture a webview screenshot as base64 PNG"}, s.webviewScreenshot)
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_screenshot", Description: "Capture a webview screenshot as base64 PNG"}, s.webviewScreenshot)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_screenshot_element", Description: "Capture a specific element as base64 PNG"}, s.webviewScreenshotElement)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "webview_scroll", Description: "Scroll a webview to an absolute position"}, s.webviewScroll)
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_scroll", Description: "Scroll a webview to an absolute position"}, s.webviewScroll)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "webview_hover", Description: "Hover over an element in a webview"}, s.webviewHover)
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_hover", Description: "Hover over an element in a webview"}, s.webviewHover)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "webview_select", Description: "Select an option in a select element"}, s.webviewSelect)
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_select", Description: "Select an option in a select element"}, s.webviewSelect)
|
||||||
|
|
@ -400,9 +681,24 @@ func (s *Subsystem) registerWebviewTools(server *mcp.Server) {
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "webview_viewport", Description: "Set the webview viewport dimensions"}, s.webviewViewport)
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_viewport", Description: "Set the webview viewport dimensions"}, s.webviewViewport)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "webview_console", Description: "Get captured console messages from a webview"}, s.webviewConsole)
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_console", Description: "Get captured console messages from a webview"}, s.webviewConsole)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "webview_console_clear", Description: "Clear captured console messages"}, s.webviewConsoleClear)
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_console_clear", Description: "Clear captured console messages"}, s.webviewConsoleClear)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_clear_console", Description: "Alias for webview_console_clear"}, s.webviewConsoleClear)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_errors", Description: "Get captured JavaScript exceptions from a webview"}, s.webviewErrors)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "webview_query", Description: "Find a single DOM element by CSS selector"}, s.webviewQuery)
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_query", Description: "Find a single DOM element by CSS selector"}, s.webviewQuery)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_element_info", Description: "Get detailed information about a DOM element"}, s.webviewElementInfo)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "webview_query_all", Description: "Find all DOM elements matching a CSS selector"}, s.webviewQueryAll)
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_query_all", Description: "Find all DOM elements matching a CSS selector"}, s.webviewQueryAll)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "webview_dom_tree", Description: "Get HTML content of a webview"}, s.webviewDOMTree)
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_dom_tree", Description: "Get HTML content of a webview"}, s.webviewDOMTree)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_source", Description: "Get page HTML source"}, s.webviewSource)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_computed_style", Description: "Get computed styles for an element"}, s.webviewComputedStyle)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_performance", Description: "Get page performance metrics"}, s.webviewPerformance)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_resources", Description: "List loaded page resources"}, s.webviewResources)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_network", Description: "Get captured network requests"}, s.webviewNetwork)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_network_inject", Description: "Inject fetch/XHR network logging"}, s.webviewNetworkInject)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_network_clear", Description: "Clear captured network requests"}, s.webviewNetworkClear)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_highlight", Description: "Visually highlight an element"}, s.webviewHighlight)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_print", Description: "Open the browser print dialog"}, s.webviewPrint)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_pdf", Description: "Export the current page as a PDF"}, s.webviewPDF)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "webview_url", Description: "Get the current URL of a webview"}, s.webviewURL)
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_url", Description: "Get the current URL of a webview"}, s.webviewURL)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "webview_title", Description: "Get the current page title of a webview"}, s.webviewTitle)
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_title", Description: "Get the current page title of a webview"}, s.webviewTitle)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_devtools_open", Description: "Open devtools for a webview window"}, s.webviewDevToolsOpen)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "webview_devtools_close", Description: "Close devtools for a webview window"}, s.webviewDevToolsClose)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ package mcp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
|
||||||
"forge.lthn.ai/core/gui/pkg/window"
|
"forge.lthn.ai/core/gui/pkg/window"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
)
|
)
|
||||||
|
|
@ -23,7 +23,7 @@ func (s *Subsystem) windowList(_ context.Context, _ *mcp.CallToolRequest, _ Wind
|
||||||
}
|
}
|
||||||
windows, ok := result.([]window.WindowInfo)
|
windows, ok := result.([]window.WindowInfo)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, WindowListOutput{}, coreerr.E("mcp.windowList", "unexpected result type from window list query", nil)
|
return nil, WindowListOutput{}, fmt.Errorf("unexpected result type from window list query")
|
||||||
}
|
}
|
||||||
return nil, WindowListOutput{Windows: windows}, 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)
|
info, ok := result.(*window.WindowInfo)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, WindowGetOutput{}, coreerr.E("mcp.windowGet", "unexpected result type from window get query", nil)
|
return nil, WindowGetOutput{}, fmt.Errorf("unexpected result type from window get query")
|
||||||
}
|
}
|
||||||
return nil, WindowGetOutput{Window: info}, 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)
|
windows, ok := result.([]window.WindowInfo)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, WindowFocusedOutput{}, coreerr.E("mcp.windowFocused", "unexpected result type from window list query", nil)
|
return nil, WindowFocusedOutput{}, fmt.Errorf("unexpected result type from window list query")
|
||||||
}
|
}
|
||||||
for _, w := range windows {
|
for _, w := range windows {
|
||||||
if w.Focused {
|
if w.Focused {
|
||||||
|
|
@ -75,23 +75,37 @@ func (s *Subsystem) windowFocused(_ context.Context, _ *mcp.CallToolRequest, _ W
|
||||||
|
|
||||||
// --- window_create ---
|
// --- 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 {
|
type WindowCreateOutput struct {
|
||||||
Window window.WindowInfo `json:"window"`
|
Window window.WindowInfo `json:"window"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) windowCreate(_ context.Context, _ *mcp.CallToolRequest, input window.Window) (*mcp.CallToolResult, WindowCreateOutput, error) {
|
func (s *Subsystem) windowCreate(_ context.Context, _ *mcp.CallToolRequest, input WindowCreateInput) (*mcp.CallToolResult, WindowCreateOutput, error) {
|
||||||
if input.Name == "" {
|
|
||||||
return nil, WindowCreateOutput{}, coreerr.E("mcp.windowCreate", "window name is required", nil)
|
|
||||||
}
|
|
||||||
result, _, err := s.core.PERFORM(window.TaskOpenWindow{
|
result, _, err := s.core.PERFORM(window.TaskOpenWindow{
|
||||||
Window: input,
|
Window: &window.Window{
|
||||||
|
Name: input.Name,
|
||||||
|
Title: input.Title,
|
||||||
|
URL: input.URL,
|
||||||
|
Width: input.Width,
|
||||||
|
Height: input.Height,
|
||||||
|
X: input.X,
|
||||||
|
Y: input.Y,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, WindowCreateOutput{}, err
|
return nil, WindowCreateOutput{}, err
|
||||||
}
|
}
|
||||||
info, ok := result.(window.WindowInfo)
|
info, ok := result.(window.WindowInfo)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, WindowCreateOutput{}, coreerr.E("mcp.windowCreate", "unexpected result type from window create task", nil)
|
return nil, WindowCreateOutput{}, fmt.Errorf("unexpected result type from window create task")
|
||||||
}
|
}
|
||||||
return nil, WindowCreateOutput{Window: info}, nil
|
return nil, WindowCreateOutput{Window: info}, nil
|
||||||
}
|
}
|
||||||
|
|
@ -165,9 +179,11 @@ type WindowBoundsOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) windowBounds(_ context.Context, _ *mcp.CallToolRequest, input WindowBoundsInput) (*mcp.CallToolResult, WindowBoundsOutput, error) {
|
func (s *Subsystem) windowBounds(_ context.Context, _ *mcp.CallToolRequest, input WindowBoundsInput) (*mcp.CallToolResult, WindowBoundsOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(window.TaskSetBounds{
|
_, _, err := s.core.PERFORM(window.TaskSetPosition{Name: input.Name, X: input.X, Y: input.Y})
|
||||||
Name: input.Name, X: input.X, Y: input.Y, Width: input.Width, Height: input.Height,
|
if err != nil {
|
||||||
})
|
return nil, WindowBoundsOutput{}, err
|
||||||
|
}
|
||||||
|
_, _, err = s.core.PERFORM(window.TaskSetSize{Name: input.Name, Width: input.Width, Height: input.Height})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, WindowBoundsOutput{}, err
|
return nil, WindowBoundsOutput{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -184,7 +200,7 @@ type WindowMaximizeOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) windowMaximize(_ context.Context, _ *mcp.CallToolRequest, input WindowMaximizeInput) (*mcp.CallToolResult, WindowMaximizeOutput, error) {
|
func (s *Subsystem) windowMaximize(_ context.Context, _ *mcp.CallToolRequest, input WindowMaximizeInput) (*mcp.CallToolResult, WindowMaximizeOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(window.TaskMaximize{Name: input.Name})
|
_, _, err := s.core.PERFORM(window.TaskMaximise{Name: input.Name})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, WindowMaximizeOutput{}, err
|
return nil, WindowMaximizeOutput{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -201,7 +217,7 @@ type WindowMinimizeOutput struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Subsystem) windowMinimize(_ context.Context, _ *mcp.CallToolRequest, input WindowMinimizeInput) (*mcp.CallToolResult, WindowMinimizeOutput, error) {
|
func (s *Subsystem) windowMinimize(_ context.Context, _ *mcp.CallToolRequest, input WindowMinimizeInput) (*mcp.CallToolResult, WindowMinimizeOutput, error) {
|
||||||
_, _, err := s.core.PERFORM(window.TaskMinimize{Name: input.Name})
|
_, _, err := s.core.PERFORM(window.TaskMinimise{Name: input.Name})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, WindowMinimizeOutput{}, err
|
return nil, WindowMinimizeOutput{}, err
|
||||||
}
|
}
|
||||||
|
|
@ -260,6 +276,12 @@ func (s *Subsystem) windowTitle(_ context.Context, _ *mcp.CallToolRequest, input
|
||||||
return nil, WindowTitleOutput{Success: true}, nil
|
return nil, WindowTitleOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- window_title_set ---
|
||||||
|
|
||||||
|
func (s *Subsystem) windowTitleSet(ctx context.Context, req *mcp.CallToolRequest, input WindowTitleInput) (*mcp.CallToolResult, WindowTitleOutput, error) {
|
||||||
|
return s.windowTitle(ctx, req, input)
|
||||||
|
}
|
||||||
|
|
||||||
// --- window_title_get ---
|
// --- window_title_get ---
|
||||||
|
|
||||||
type WindowTitleGetInput struct {
|
type WindowTitleGetInput struct {
|
||||||
|
|
@ -340,6 +362,24 @@ func (s *Subsystem) windowBackgroundColour(_ context.Context, _ *mcp.CallToolReq
|
||||||
return nil, WindowBackgroundColourOutput{Success: true}, nil
|
return nil, WindowBackgroundColourOutput{Success: true}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- window_opacity ---
|
||||||
|
|
||||||
|
type WindowOpacityInput struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Opacity float32 `json:"opacity"`
|
||||||
|
}
|
||||||
|
type WindowOpacityOutput struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Subsystem) windowOpacity(_ context.Context, _ *mcp.CallToolRequest, input WindowOpacityInput) (*mcp.CallToolResult, WindowOpacityOutput, error) {
|
||||||
|
_, _, err := s.core.PERFORM(window.TaskSetOpacity{Name: input.Name, Opacity: input.Opacity})
|
||||||
|
if err != nil {
|
||||||
|
return nil, WindowOpacityOutput{}, err
|
||||||
|
}
|
||||||
|
return nil, WindowOpacityOutput{Success: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// --- window_fullscreen ---
|
// --- window_fullscreen ---
|
||||||
|
|
||||||
type WindowFullscreenInput struct {
|
type WindowFullscreenInput struct {
|
||||||
|
|
@ -364,19 +404,22 @@ 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_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_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_focused", Description: "Get the currently focused window"}, s.windowFocused)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "window_create", Description: "Create a new named application window"}, s.windowCreate)
|
mcp.AddTool(server, &mcp.Tool{Name: "window_create", Description: "Create a new 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_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_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_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_bounds", Description: "Set both position and size of a window"}, s.windowBounds)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "window_maximize", Description: "Maximize a window"}, s.windowMaximize)
|
mcp.AddTool(server, &mcp.Tool{Name: "window_maximize", Description: "Maximise 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_minimize", Description: "Minimise 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_restore", Description: "Restore a maximised or minimised 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_focus", Description: "Bring a window to the front"}, s.windowFocus)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "focus_set", Description: "Alias for window_focus"}, 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", Description: "Set the title of a window"}, s.windowTitle)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "window_title_set", Description: "Alias for window_title"}, s.windowTitleSet)
|
||||||
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_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_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_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_background_colour", Description: "Set a window background colour"}, s.windowBackgroundColour)
|
||||||
|
mcp.AddTool(server, &mcp.Tool{Name: "window_opacity", Description: "Set a window opacity"}, s.windowOpacity)
|
||||||
mcp.AddTool(server, &mcp.Tool{Name: "window_fullscreen", Description: "Set a window to fullscreen mode"}, s.windowFullscreen)
|
mcp.AddTool(server, &mcp.Tool{Name: "window_fullscreen", Description: "Set a window to fullscreen mode"}, s.windowFullscreen)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
package menu
|
package menu
|
||||||
|
|
||||||
// MenuItem describes a menu item for construction (structure only — no handlers).
|
// MenuItem describes a menu item for construction (structure only — no handlers).
|
||||||
|
// Use: item := menu.MenuItem{Label: "Quit", OnClick: func() {}}
|
||||||
type MenuItem struct {
|
type MenuItem struct {
|
||||||
Label string
|
Label string
|
||||||
Accelerator string
|
Accelerator string
|
||||||
|
|
@ -15,16 +16,19 @@ type MenuItem struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manager builds application menus via a Platform backend.
|
// Manager builds application menus via a Platform backend.
|
||||||
|
// Use: mgr := menu.NewManager(platform)
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
platform Platform
|
platform Platform
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewManager creates a menu Manager.
|
// NewManager creates a menu Manager.
|
||||||
|
// Use: mgr := menu.NewManager(platform)
|
||||||
func NewManager(platform Platform) *Manager {
|
func NewManager(platform Platform) *Manager {
|
||||||
return &Manager{platform: platform}
|
return &Manager{platform: platform}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build constructs a PlatformMenu from a tree of MenuItems.
|
// Build constructs a PlatformMenu from a tree of MenuItems.
|
||||||
|
// Use: built := mgr.Build([]menu.MenuItem{{Label: "File"}})
|
||||||
func (m *Manager) Build(items []MenuItem) PlatformMenu {
|
func (m *Manager) Build(items []MenuItem) PlatformMenu {
|
||||||
menu := m.platform.NewMenu()
|
menu := m.platform.NewMenu()
|
||||||
m.buildItems(menu, items)
|
m.buildItems(menu, items)
|
||||||
|
|
@ -60,12 +64,14 @@ func (m *Manager) buildItems(menu PlatformMenu, items []MenuItem) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetApplicationMenu builds and sets the application menu.
|
// SetApplicationMenu builds and sets the application menu.
|
||||||
|
// Use: mgr.SetApplicationMenu([]menu.MenuItem{{Label: "Quit"}})
|
||||||
func (m *Manager) SetApplicationMenu(items []MenuItem) {
|
func (m *Manager) SetApplicationMenu(items []MenuItem) {
|
||||||
menu := m.Build(items)
|
menu := m.Build(items)
|
||||||
m.platform.SetApplicationMenu(menu)
|
m.platform.SetApplicationMenu(menu)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Platform returns the underlying platform.
|
// Platform returns the underlying platform.
|
||||||
|
// Use: backend := mgr.Platform()
|
||||||
func (m *Manager) Platform() Platform {
|
func (m *Manager) Platform() Platform {
|
||||||
return m.platform
|
return m.platform
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,17 @@
|
||||||
|
// pkg/menu/messages.go
|
||||||
package menu
|
package menu
|
||||||
|
|
||||||
|
// QueryConfig requests this service's config section from the display orchestrator.
|
||||||
|
// Result: map[string]any
|
||||||
type QueryConfig struct{}
|
type QueryConfig struct{}
|
||||||
|
|
||||||
|
// QueryGetAppMenu returns the current app menu item descriptors.
|
||||||
|
// Result: []MenuItem
|
||||||
type QueryGetAppMenu struct{}
|
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 }
|
type TaskSetAppMenu struct{ Items []MenuItem }
|
||||||
|
|
||||||
type TaskSaveConfig struct{ Config map[string]any }
|
// TaskSaveConfig persists this service's config section via the display orchestrator.
|
||||||
|
type TaskSaveConfig struct{ Value map[string]any }
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// pkg/menu/mock_platform.go
|
||||||
package menu
|
package menu
|
||||||
|
|
||||||
// MockPlatform is an exported mock for cross-package integration tests.
|
// MockPlatform is an exported mock for cross-package integration tests.
|
||||||
|
|
@ -10,9 +11,13 @@ func (m *MockPlatform) SetApplicationMenu(menu PlatformMenu) {}
|
||||||
|
|
||||||
type exportedMockPlatformMenu struct{}
|
type exportedMockPlatformMenu struct{}
|
||||||
|
|
||||||
func (m *exportedMockPlatformMenu) Add(label string) PlatformMenuItem { return &exportedMockPlatformMenuItem{} }
|
func (m *exportedMockPlatformMenu) Add(label string) PlatformMenuItem {
|
||||||
|
return &exportedMockPlatformMenuItem{}
|
||||||
|
}
|
||||||
func (m *exportedMockPlatformMenu) AddSeparator() {}
|
func (m *exportedMockPlatformMenu) AddSeparator() {}
|
||||||
func (m *exportedMockPlatformMenu) AddSubmenu(label string) PlatformMenu { return &exportedMockPlatformMenu{} }
|
func (m *exportedMockPlatformMenu) AddSubmenu(label string) PlatformMenu {
|
||||||
|
return &exportedMockPlatformMenu{}
|
||||||
|
}
|
||||||
func (m *exportedMockPlatformMenu) AddRole(role MenuRole) {}
|
func (m *exportedMockPlatformMenu) AddRole(role MenuRole) {}
|
||||||
|
|
||||||
type exportedMockPlatformMenuItem struct{}
|
type exportedMockPlatformMenuItem struct{}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,14 @@
|
||||||
package menu
|
package menu
|
||||||
|
|
||||||
// Platform abstracts the menu backend.
|
// Platform abstracts the menu backend.
|
||||||
|
// Use: var platform menu.Platform = backend
|
||||||
type Platform interface {
|
type Platform interface {
|
||||||
NewMenu() PlatformMenu
|
NewMenu() PlatformMenu
|
||||||
SetApplicationMenu(menu PlatformMenu)
|
SetApplicationMenu(menu PlatformMenu)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PlatformMenu is a live menu handle.
|
// PlatformMenu is a live menu handle.
|
||||||
|
// Use: var root menu.PlatformMenu = platform.NewMenu()
|
||||||
type PlatformMenu interface {
|
type PlatformMenu interface {
|
||||||
Add(label string) PlatformMenuItem
|
Add(label string) PlatformMenuItem
|
||||||
AddSeparator()
|
AddSeparator()
|
||||||
|
|
@ -17,6 +19,7 @@ type PlatformMenu interface {
|
||||||
}
|
}
|
||||||
|
|
||||||
// PlatformMenuItem is a single menu item.
|
// PlatformMenuItem is a single menu item.
|
||||||
|
// Use: var item menu.PlatformMenuItem = root.Add("Quit")
|
||||||
type PlatformMenuItem interface {
|
type PlatformMenuItem interface {
|
||||||
SetAccelerator(accel string) PlatformMenuItem
|
SetAccelerator(accel string) PlatformMenuItem
|
||||||
SetTooltip(text string) PlatformMenuItem
|
SetTooltip(text string) PlatformMenuItem
|
||||||
|
|
@ -26,6 +29,7 @@ type PlatformMenuItem interface {
|
||||||
}
|
}
|
||||||
|
|
||||||
// MenuRole is a predefined platform menu role.
|
// MenuRole is a predefined platform menu role.
|
||||||
|
// Use: role := menu.RoleFileMenu
|
||||||
type MenuRole int
|
type MenuRole int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
|
// pkg/menu/register.go
|
||||||
package menu
|
package menu
|
||||||
|
|
||||||
import "forge.lthn.ai/core/go/pkg/core"
|
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) {
|
func Register(p Platform) func(*core.Core) (any, error) {
|
||||||
return func(c *core.Core) (any, error) {
|
return func(c *core.Core) (any, error) {
|
||||||
return &Service{
|
return &Service{
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// pkg/menu/service.go
|
||||||
package menu
|
package menu
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|
@ -6,21 +7,24 @@ import (
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
"forge.lthn.ai/core/go/pkg/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Options holds configuration for the menu service.
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
|
|
||||||
|
// Service is a core.Service managing application menus via IPC.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
*core.ServiceRuntime[Options]
|
*core.ServiceRuntime[Options]
|
||||||
manager *Manager
|
manager *Manager
|
||||||
platform Platform
|
platform Platform
|
||||||
menuItems []MenuItem
|
items []MenuItem // last-set menu items for QueryGetAppMenu
|
||||||
showDevTools bool
|
showDevTools bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OnStartup queries config and registers IPC handlers.
|
||||||
func (s *Service) OnStartup(ctx context.Context) error {
|
func (s *Service) OnStartup(ctx context.Context) error {
|
||||||
configValue, handled, _ := s.Core().QUERY(QueryConfig{})
|
cfg, handled, _ := s.Core().QUERY(QueryConfig{})
|
||||||
if handled {
|
if handled {
|
||||||
if menuConfig, ok := configValue.(map[string]any); ok {
|
if mCfg, ok := cfg.(map[string]any); ok {
|
||||||
s.applyConfig(menuConfig)
|
s.applyConfig(mCfg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.Core().RegisterQuery(s.handleQuery)
|
s.Core().RegisterQuery(s.handleQuery)
|
||||||
|
|
@ -28,18 +32,20 @@ func (s *Service) OnStartup(ctx context.Context) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) applyConfig(configData map[string]any) {
|
func (s *Service) applyConfig(cfg map[string]any) {
|
||||||
if v, ok := configData["show_dev_tools"]; ok {
|
if v, ok := cfg["show_dev_tools"]; ok {
|
||||||
if show, ok := v.(bool); ok {
|
if show, ok := v.(bool); ok {
|
||||||
s.showDevTools = show
|
s.showDevTools = show
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ShowDevTools returns whether developer tools menu items should be shown.
|
||||||
func (s *Service) ShowDevTools() bool {
|
func (s *Service) ShowDevTools() bool {
|
||||||
return s.showDevTools
|
return s.showDevTools
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleIPCEvents is auto-discovered and registered by core.WithService.
|
||||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -47,7 +53,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) {
|
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
||||||
switch q.(type) {
|
switch q.(type) {
|
||||||
case QueryGetAppMenu:
|
case QueryGetAppMenu:
|
||||||
return s.menuItems, true, nil
|
return s.items, true, nil
|
||||||
default:
|
default:
|
||||||
return nil, false, nil
|
return nil, false, nil
|
||||||
}
|
}
|
||||||
|
|
@ -56,7 +62,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) {
|
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
||||||
switch t := t.(type) {
|
switch t := t.(type) {
|
||||||
case TaskSetAppMenu:
|
case TaskSetAppMenu:
|
||||||
s.menuItems = t.Items
|
s.items = t.Items
|
||||||
s.manager.SetApplicationMenu(t.Items)
|
s.manager.SetApplicationMenu(t.Items)
|
||||||
return nil, true, nil
|
return nil, true, nil
|
||||||
default:
|
default:
|
||||||
|
|
@ -64,6 +70,7 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Manager returns the underlying menu Manager.
|
||||||
func (s *Service) Manager() *Manager {
|
func (s *Service) Manager() *Manager {
|
||||||
return s.manager
|
return s.manager
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,17 @@
|
||||||
|
// pkg/notification/messages.go
|
||||||
package notification
|
package notification
|
||||||
|
|
||||||
|
// QueryPermission checks notification authorisation. Result: PermissionStatus
|
||||||
type QueryPermission struct{}
|
type QueryPermission struct{}
|
||||||
|
|
||||||
type TaskSend struct{ Options NotificationOptions }
|
// TaskSend sends a notification. Falls back to dialog if platform fails.
|
||||||
|
type TaskSend struct{ Opts NotificationOptions }
|
||||||
|
|
||||||
|
// TaskRequestPermission requests notification authorisation. Result: bool (granted)
|
||||||
type TaskRequestPermission struct{}
|
type TaskRequestPermission struct{}
|
||||||
|
|
||||||
|
// TaskClear clears pending notifications when the backend supports it.
|
||||||
|
type TaskClear struct{}
|
||||||
|
|
||||||
|
// ActionNotificationClicked is broadcast when a notification is clicked.
|
||||||
type ActionNotificationClicked struct{ ID string }
|
type ActionNotificationClicked struct{ ID string }
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,17 @@ package notification
|
||||||
|
|
||||||
// Platform abstracts the native notification backend.
|
// Platform abstracts the native notification backend.
|
||||||
type Platform interface {
|
type Platform interface {
|
||||||
Send(options NotificationOptions) error
|
Send(opts NotificationOptions) error
|
||||||
RequestPermission() (bool, error)
|
RequestPermission() (bool, error)
|
||||||
CheckPermission() (bool, error)
|
CheckPermission() (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NotificationAction represents an interactive notification action.
|
||||||
|
type NotificationAction struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
}
|
||||||
|
|
||||||
// NotificationSeverity indicates the severity for dialog fallback.
|
// NotificationSeverity indicates the severity for dialog fallback.
|
||||||
type NotificationSeverity int
|
type NotificationSeverity int
|
||||||
|
|
||||||
|
|
@ -24,9 +30,18 @@ type NotificationOptions struct {
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
Subtitle string `json:"subtitle,omitempty"`
|
Subtitle string `json:"subtitle,omitempty"`
|
||||||
Severity NotificationSeverity `json:"severity,omitempty"`
|
Severity NotificationSeverity `json:"severity,omitempty"`
|
||||||
|
Actions []NotificationAction `json:"actions,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PermissionStatus indicates whether notifications are authorised.
|
// PermissionStatus indicates whether notifications are authorised.
|
||||||
type PermissionStatus struct {
|
type PermissionStatus struct {
|
||||||
Granted bool `json:"granted"`
|
Granted bool `json:"granted"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type clearer interface {
|
||||||
|
Clear() error
|
||||||
|
}
|
||||||
|
|
||||||
|
type actionSender interface {
|
||||||
|
SendWithActions(opts NotificationOptions) error
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,20 +3,26 @@ package notification
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"strconv"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
"forge.lthn.ai/core/go/pkg/core"
|
||||||
"forge.lthn.ai/core/gui/pkg/dialog"
|
"forge.lthn.ai/core/gui/pkg/dialog"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Options configures the notification service.
|
||||||
|
// Use: core.WithService(notification.Register(platform))
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
|
|
||||||
|
// Service manages notifications via Core tasks and queries.
|
||||||
|
// Use: svc := ¬ification.Service{}
|
||||||
type Service struct {
|
type Service struct {
|
||||||
*core.ServiceRuntime[Options]
|
*core.ServiceRuntime[Options]
|
||||||
platform Platform
|
platform Platform
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register creates a Core service factory for the notification backend.
|
||||||
|
// Use: core.New(core.WithService(notification.Register(platform)))
|
||||||
func Register(p Platform) func(*core.Core) (any, error) {
|
func Register(p Platform) func(*core.Core) (any, error) {
|
||||||
return func(c *core.Core) (any, error) {
|
return func(c *core.Core) (any, error) {
|
||||||
return &Service{
|
return &Service{
|
||||||
|
|
@ -26,12 +32,15 @@ func Register(p Platform) func(*core.Core) (any, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OnStartup registers notification handlers with Core.
|
||||||
|
// Use: _ = svc.OnStartup(context.Background())
|
||||||
func (s *Service) OnStartup(ctx context.Context) error {
|
func (s *Service) OnStartup(ctx context.Context) error {
|
||||||
s.Core().RegisterQuery(s.handleQuery)
|
s.Core().RegisterQuery(s.handleQuery)
|
||||||
s.Core().RegisterTask(s.handleTask)
|
s.Core().RegisterTask(s.handleTask)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleIPCEvents satisfies Core's IPC hook.
|
||||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -49,34 +58,47 @@ 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) {
|
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
||||||
switch t := t.(type) {
|
switch t := t.(type) {
|
||||||
case TaskSend:
|
case TaskSend:
|
||||||
return nil, true, s.send(t.Options)
|
return nil, true, s.sendNotification(t.Opts)
|
||||||
case TaskRequestPermission:
|
case TaskRequestPermission:
|
||||||
granted, err := s.platform.RequestPermission()
|
granted, err := s.platform.RequestPermission()
|
||||||
return granted, true, err
|
return granted, true, err
|
||||||
|
case TaskClear:
|
||||||
|
if clr, ok := s.platform.(clearer); ok {
|
||||||
|
return nil, true, clr.Clear()
|
||||||
|
}
|
||||||
|
return nil, true, nil
|
||||||
default:
|
default:
|
||||||
return nil, false, nil
|
return nil, false, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// send attempts native notification, falls back to dialog via IPC.
|
// sendNotification attempts a native notification and falls back to a dialog via IPC.
|
||||||
func (s *Service) send(options NotificationOptions) error {
|
func (s *Service) sendNotification(opts NotificationOptions) error {
|
||||||
// Generate ID if not provided
|
// Generate an ID when the caller does not provide one.
|
||||||
if options.ID == "" {
|
if opts.ID == "" {
|
||||||
options.ID = "core-" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
opts.ID = fmt.Sprintf("core-%d", time.Now().UnixNano())
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.platform.Send(options); err != nil {
|
if len(opts.Actions) > 0 {
|
||||||
// Fallback: show as dialog via IPC
|
if sender, ok := s.platform.(actionSender); ok {
|
||||||
return s.fallbackDialog(options)
|
if err := sender.SendWithActions(opts); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.platform.Send(opts); err != nil {
|
||||||
|
// Fall back to a dialog when the native notification fails.
|
||||||
|
return s.showFallbackDialog(opts)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// fallbackDialog shows a dialog via IPC when native notifications fail.
|
// showFallbackDialog shows a dialog via IPC when native notifications fail.
|
||||||
func (s *Service) fallbackDialog(options NotificationOptions) error {
|
func (s *Service) showFallbackDialog(opts NotificationOptions) error {
|
||||||
// Map severity to dialog type
|
// Map severity to dialog type.
|
||||||
var dt dialog.DialogType
|
var dt dialog.DialogType
|
||||||
switch options.Severity {
|
switch opts.Severity {
|
||||||
case SeverityWarning:
|
case SeverityWarning:
|
||||||
dt = dialog.DialogWarning
|
dt = dialog.DialogWarning
|
||||||
case SeverityError:
|
case SeverityError:
|
||||||
|
|
@ -85,15 +107,15 @@ func (s *Service) fallbackDialog(options NotificationOptions) error {
|
||||||
dt = dialog.DialogInfo
|
dt = dialog.DialogInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
msg := options.Message
|
msg := opts.Message
|
||||||
if options.Subtitle != "" {
|
if opts.Subtitle != "" {
|
||||||
msg = options.Subtitle + "\n\n" + msg
|
msg = opts.Subtitle + "\n\n" + msg
|
||||||
}
|
}
|
||||||
|
|
||||||
_, _, err := s.Core().PERFORM(dialog.TaskMessageDialog{
|
_, _, err := s.Core().PERFORM(dialog.TaskMessageDialog{
|
||||||
Options: dialog.MessageDialogOptions{
|
Opts: dialog.MessageDialogOptions{
|
||||||
Type: dt,
|
Type: dt,
|
||||||
Title: options.Title,
|
Title: opts.Title,
|
||||||
Message: msg,
|
Message: msg,
|
||||||
Buttons: []string{"OK"},
|
Buttons: []string{"OK"},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ type mockPlatform struct {
|
||||||
permErr error
|
permErr error
|
||||||
lastOpts NotificationOptions
|
lastOpts NotificationOptions
|
||||||
sendCalled bool
|
sendCalled bool
|
||||||
|
clearCalled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockPlatform) Send(opts NotificationOptions) error {
|
func (m *mockPlatform) Send(opts NotificationOptions) error {
|
||||||
|
|
@ -25,8 +26,14 @@ func (m *mockPlatform) Send(opts NotificationOptions) error {
|
||||||
m.lastOpts = opts
|
m.lastOpts = opts
|
||||||
return m.sendErr
|
return m.sendErr
|
||||||
}
|
}
|
||||||
|
func (m *mockPlatform) SendWithActions(opts NotificationOptions) error {
|
||||||
|
m.sendCalled = true
|
||||||
|
m.lastOpts = opts
|
||||||
|
return m.sendErr
|
||||||
|
}
|
||||||
func (m *mockPlatform) RequestPermission() (bool, error) { return m.permGranted, m.permErr }
|
func (m *mockPlatform) RequestPermission() (bool, error) { return m.permGranted, m.permErr }
|
||||||
func (m *mockPlatform) CheckPermission() (bool, error) { return m.permGranted, m.permErr }
|
func (m *mockPlatform) CheckPermission() (bool, error) { return m.permGranted, m.permErr }
|
||||||
|
func (m *mockPlatform) Clear() error { m.clearCalled = true; return nil }
|
||||||
|
|
||||||
// mockDialogPlatform tracks whether MessageDialog was called (for fallback test).
|
// mockDialogPlatform tracks whether MessageDialog was called (for fallback test).
|
||||||
type mockDialogPlatform struct {
|
type mockDialogPlatform struct {
|
||||||
|
|
@ -66,7 +73,7 @@ func TestRegister_Good(t *testing.T) {
|
||||||
func TestTaskSend_Good(t *testing.T) {
|
func TestTaskSend_Good(t *testing.T) {
|
||||||
mock, c := newTestService(t)
|
mock, c := newTestService(t)
|
||||||
_, handled, err := c.PERFORM(TaskSend{
|
_, handled, err := c.PERFORM(TaskSend{
|
||||||
Options: NotificationOptions{Title: "Test", Message: "Hello"},
|
Opts: NotificationOptions{Title: "Test", Message: "Hello"},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, handled)
|
assert.True(t, handled)
|
||||||
|
|
@ -87,7 +94,7 @@ func TestTaskSend_Fallback_Good(t *testing.T) {
|
||||||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
||||||
|
|
||||||
_, handled, err := c.PERFORM(TaskSend{
|
_, handled, err := c.PERFORM(TaskSend{
|
||||||
Options: NotificationOptions{Title: "Warn", Message: "Oops", Severity: SeverityWarning},
|
Opts: NotificationOptions{Title: "Warn", Message: "Oops", Severity: SeverityWarning},
|
||||||
})
|
})
|
||||||
assert.True(t, handled)
|
assert.True(t, handled)
|
||||||
assert.NoError(t, err) // fallback succeeds even though platform failed
|
assert.NoError(t, err) // fallback succeeds even though platform failed
|
||||||
|
|
@ -117,3 +124,26 @@ func TestTaskSend_Bad(t *testing.T) {
|
||||||
_, handled, _ := c.PERFORM(TaskSend{})
|
_, handled, _ := c.PERFORM(TaskSend{})
|
||||||
assert.False(t, handled)
|
assert.False(t, handled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTaskSend_WithActions_Good(t *testing.T) {
|
||||||
|
mock, c := newTestService(t)
|
||||||
|
_, handled, err := c.PERFORM(TaskSend{
|
||||||
|
Opts: NotificationOptions{
|
||||||
|
Title: "Test",
|
||||||
|
Message: "Hello",
|
||||||
|
Actions: []NotificationAction{{ID: "ok", Label: "OK"}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
assert.True(t, mock.sendCalled)
|
||||||
|
assert.Len(t, mock.lastOpts.Actions, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskClear_Good(t *testing.T) {
|
||||||
|
mock, c := newTestService(t)
|
||||||
|
_, handled, err := c.PERFORM(TaskClear{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
assert.True(t, mock.clearCalled)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,19 +2,25 @@
|
||||||
package screen
|
package screen
|
||||||
|
|
||||||
// QueryAll returns all screens. Result: []Screen
|
// QueryAll returns all screens. Result: []Screen
|
||||||
|
// Use: result, _, err := c.QUERY(screen.QueryAll{})
|
||||||
type QueryAll struct{}
|
type QueryAll struct{}
|
||||||
|
|
||||||
// QueryPrimary returns the primary screen. Result: *Screen (nil if not found)
|
// QueryPrimary returns the primary screen. Result: *Screen (nil if not found)
|
||||||
|
// Use: result, _, err := c.QUERY(screen.QueryPrimary{})
|
||||||
type QueryPrimary struct{}
|
type QueryPrimary struct{}
|
||||||
|
|
||||||
// QueryByID returns a screen by ID. Result: *Screen (nil if not found)
|
// QueryByID returns a screen by ID. Result: *Screen (nil if not found)
|
||||||
|
// Use: result, _, err := c.QUERY(screen.QueryByID{ID: "display-1"})
|
||||||
type QueryByID struct{ ID string }
|
type QueryByID struct{ ID string }
|
||||||
|
|
||||||
// QueryAtPoint returns the screen containing a point. Result: *Screen (nil if none)
|
// QueryAtPoint returns the screen containing a point. Result: *Screen (nil if none)
|
||||||
|
// Use: result, _, err := c.QUERY(screen.QueryAtPoint{X: 100, Y: 100})
|
||||||
type QueryAtPoint struct{ X, Y int }
|
type QueryAtPoint struct{ X, Y int }
|
||||||
|
|
||||||
// QueryWorkAreas returns work areas for all screens. Result: []Rect
|
// QueryWorkAreas returns work areas for all screens. Result: []Rect
|
||||||
|
// Use: result, _, err := c.QUERY(screen.QueryWorkAreas{})
|
||||||
type QueryWorkAreas struct{}
|
type QueryWorkAreas struct{}
|
||||||
|
|
||||||
// ActionScreensChanged is broadcast when displays change (future).
|
// ActionScreensChanged is broadcast when displays change.
|
||||||
|
// Use: _ = c.ACTION(screen.ActionScreensChanged{Screens: screens})
|
||||||
type ActionScreensChanged struct{ Screens []Screen }
|
type ActionScreensChanged struct{ Screens []Screen }
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,14 @@
|
||||||
package screen
|
package screen
|
||||||
|
|
||||||
// Platform abstracts the screen/display backend.
|
// Platform abstracts the screen/display backend.
|
||||||
|
// Use: var p screen.Platform
|
||||||
type Platform interface {
|
type Platform interface {
|
||||||
GetAll() []Screen
|
GetAll() []Screen
|
||||||
GetPrimary() *Screen
|
GetPrimary() *Screen
|
||||||
}
|
}
|
||||||
|
|
||||||
// Screen describes a display/monitor.
|
// Screen describes a display/monitor.
|
||||||
|
// Use: scr := screen.Screen{ID: "display-1"}
|
||||||
type Screen struct {
|
type Screen struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|
@ -20,6 +22,7 @@ type Screen struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rect represents a rectangle with position and dimensions.
|
// Rect represents a rectangle with position and dimensions.
|
||||||
|
// Use: rect := screen.Rect{X: 0, Y: 0, Width: 1920, Height: 1080}
|
||||||
type Rect struct {
|
type Rect struct {
|
||||||
X int `json:"x"`
|
X int `json:"x"`
|
||||||
Y int `json:"y"`
|
Y int `json:"y"`
|
||||||
|
|
@ -28,6 +31,7 @@ type Rect struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Size represents dimensions.
|
// Size represents dimensions.
|
||||||
|
// Use: size := screen.Size{Width: 1920, Height: 1080}
|
||||||
type Size struct {
|
type Size struct {
|
||||||
Width int `json:"width"`
|
Width int `json:"width"`
|
||||||
Height int `json:"height"`
|
Height int `json:"height"`
|
||||||
|
|
|
||||||
|
|
@ -8,15 +8,18 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// Options holds configuration for the screen service.
|
// Options holds configuration for the screen service.
|
||||||
|
// Use: svc, err := screen.Register(platform)(core.New())
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
|
|
||||||
// Service is a core.Service providing screen/display queries via IPC.
|
// Service is a core.Service providing screen/display queries via IPC.
|
||||||
|
// Use: svc, err := screen.Register(platform)(core.New())
|
||||||
type Service struct {
|
type Service struct {
|
||||||
*core.ServiceRuntime[Options]
|
*core.ServiceRuntime[Options]
|
||||||
platform Platform
|
platform Platform
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register creates a factory closure that captures the Platform adapter.
|
// Register creates a factory closure that captures the Platform adapter.
|
||||||
|
// Use: core.WithService(screen.Register(platform))
|
||||||
func Register(p Platform) func(*core.Core) (any, error) {
|
func Register(p Platform) func(*core.Core) (any, error) {
|
||||||
return func(c *core.Core) (any, error) {
|
return func(c *core.Core) (any, error) {
|
||||||
return &Service{
|
return &Service{
|
||||||
|
|
@ -27,12 +30,14 @@ func Register(p Platform) func(*core.Core) (any, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// OnStartup registers IPC handlers.
|
// OnStartup registers IPC handlers.
|
||||||
|
// Use: _ = svc.OnStartup(context.Background())
|
||||||
func (s *Service) OnStartup(ctx context.Context) error {
|
func (s *Service) OnStartup(ctx context.Context) error {
|
||||||
s.Core().RegisterQuery(s.handleQuery)
|
s.Core().RegisterQuery(s.handleQuery)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleIPCEvents is auto-discovered by core.WithService.
|
// HandleIPCEvents is auto-discovered by core.WithService.
|
||||||
|
// Use: _ = svc.HandleIPCEvents(core, msg)
|
||||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,28 @@
|
||||||
// pkg/systray/menu.go
|
// pkg/systray/menu.go
|
||||||
package systray
|
package systray
|
||||||
|
|
||||||
import coreerr "forge.lthn.ai/core/go-log"
|
import "forge.lthn.ai/core/go/pkg/core"
|
||||||
|
|
||||||
// SetMenu sets a dynamic menu on the tray from TrayMenuItem descriptors.
|
// SetMenu sets a dynamic menu on the tray from TrayMenuItem descriptors.
|
||||||
|
// Use: _ = m.SetMenu([]TrayMenuItem{{Label: "Quit", ActionID: "quit"}})
|
||||||
func (m *Manager) SetMenu(items []TrayMenuItem) error {
|
func (m *Manager) SetMenu(items []TrayMenuItem) error {
|
||||||
if m.tray == nil {
|
if m.tray == nil {
|
||||||
return coreerr.E("systray.Manager.SetMenu", "tray not initialised", nil)
|
return core.E("systray.SetMenu", "tray not initialised", nil)
|
||||||
}
|
}
|
||||||
menu := m.platform.NewMenu()
|
m.menuItems = append([]TrayMenuItem(nil), items...)
|
||||||
m.buildMenu(menu, items)
|
menu := m.buildMenu(items)
|
||||||
m.tray.SetMenu(menu)
|
m.tray.SetMenu(menu)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildMenu recursively builds a PlatformMenu from TrayMenuItem descriptors.
|
// buildMenu recursively builds a PlatformMenu from TrayMenuItem descriptors.
|
||||||
func (m *Manager) buildMenu(menu PlatformMenu, items []TrayMenuItem) {
|
func (m *Manager) buildMenu(items []TrayMenuItem) PlatformMenu {
|
||||||
|
menu := m.platform.NewMenu()
|
||||||
|
m.buildMenuInto(menu, items)
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) buildMenuInto(menu PlatformMenu, items []TrayMenuItem) {
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
if item.Type == "separator" {
|
if item.Type == "separator" {
|
||||||
menu.AddSeparator()
|
menu.AddSeparator()
|
||||||
|
|
@ -23,7 +30,7 @@ func (m *Manager) buildMenu(menu PlatformMenu, items []TrayMenuItem) {
|
||||||
}
|
}
|
||||||
if len(item.Submenu) > 0 {
|
if len(item.Submenu) > 0 {
|
||||||
sub := menu.AddSubmenu(item.Label)
|
sub := menu.AddSubmenu(item.Label)
|
||||||
m.buildMenu(sub, item.Submenu)
|
m.buildMenuInto(sub, item.Submenu)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
mi := menu.Add(item.Label)
|
mi := menu.Add(item.Label)
|
||||||
|
|
@ -48,6 +55,7 @@ func (m *Manager) buildMenu(menu PlatformMenu, items []TrayMenuItem) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterCallback registers a callback for a menu action ID.
|
// RegisterCallback registers a callback for a menu action ID.
|
||||||
|
// Use: m.RegisterCallback("quit", func() { _ = app.Quit() })
|
||||||
func (m *Manager) RegisterCallback(actionID string, callback func()) {
|
func (m *Manager) RegisterCallback(actionID string, callback func()) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
m.callbacks[actionID] = callback
|
m.callbacks[actionID] = callback
|
||||||
|
|
@ -55,6 +63,7 @@ func (m *Manager) RegisterCallback(actionID string, callback func()) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// UnregisterCallback removes a callback.
|
// UnregisterCallback removes a callback.
|
||||||
|
// Use: m.UnregisterCallback("quit")
|
||||||
func (m *Manager) UnregisterCallback(actionID string) {
|
func (m *Manager) UnregisterCallback(actionID string) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
delete(m.callbacks, actionID)
|
delete(m.callbacks, actionID)
|
||||||
|
|
@ -62,6 +71,7 @@ func (m *Manager) UnregisterCallback(actionID string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCallback returns the callback for an action ID.
|
// GetCallback returns the callback for an action ID.
|
||||||
|
// Use: callback, ok := m.GetCallback("quit")
|
||||||
func (m *Manager) GetCallback(actionID string) (func(), bool) {
|
func (m *Manager) GetCallback(actionID string) (func(), bool) {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
@ -70,8 +80,14 @@ func (m *Manager) GetCallback(actionID string) (func(), bool) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetInfo returns tray status information.
|
// GetInfo returns tray status information.
|
||||||
|
// Use: info := m.GetInfo()
|
||||||
func (m *Manager) GetInfo() map[string]any {
|
func (m *Manager) GetInfo() map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"active": m.IsActive(),
|
"active": m.IsActive(),
|
||||||
|
"tooltip": m.tooltip,
|
||||||
|
"label": m.label,
|
||||||
|
"hasIcon": m.hasIcon,
|
||||||
|
"hasTemplateIcon": m.hasTemplateIcon,
|
||||||
|
"menuItems": append([]TrayMenuItem(nil), m.menuItems...),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,54 @@
|
||||||
|
// pkg/systray/messages.go
|
||||||
package systray
|
package systray
|
||||||
|
|
||||||
|
// QueryConfig requests this service's config section from the display orchestrator.
|
||||||
|
// Result: map[string]any
|
||||||
|
// Use: result, _, err := c.QUERY(systray.QueryConfig{})
|
||||||
type QueryConfig struct{}
|
type QueryConfig struct{}
|
||||||
|
|
||||||
|
// --- Tasks ---
|
||||||
|
|
||||||
|
// TaskSetTrayIcon sets the tray icon.
|
||||||
|
// Use: _, _, err := c.PERFORM(systray.TaskSetTrayIcon{Data: iconBytes})
|
||||||
type TaskSetTrayIcon struct{ Data []byte }
|
type TaskSetTrayIcon struct{ Data []byte }
|
||||||
|
|
||||||
|
// TaskSetTooltip updates the tray tooltip text.
|
||||||
|
// Use: _, _, err := c.PERFORM(systray.TaskSetTooltip{Tooltip: "Core is ready"})
|
||||||
|
type TaskSetTooltip struct{ Tooltip string }
|
||||||
|
|
||||||
|
// TaskSetLabel updates the tray label text.
|
||||||
|
// Use: _, _, err := c.PERFORM(systray.TaskSetLabel{Label: "Core"})
|
||||||
|
type TaskSetLabel struct{ Label string }
|
||||||
|
|
||||||
|
// TaskSetTrayMenu sets the tray menu items.
|
||||||
|
// Use: _, _, err := c.PERFORM(systray.TaskSetTrayMenu{Items: items})
|
||||||
type TaskSetTrayMenu struct{ Items []TrayMenuItem }
|
type TaskSetTrayMenu struct{ Items []TrayMenuItem }
|
||||||
|
|
||||||
|
// TaskShowPanel shows the tray panel window.
|
||||||
|
// Use: _, _, err := c.PERFORM(systray.TaskShowPanel{})
|
||||||
type TaskShowPanel struct{}
|
type TaskShowPanel struct{}
|
||||||
|
|
||||||
|
// TaskHidePanel hides the tray panel window.
|
||||||
|
// Use: _, _, err := c.PERFORM(systray.TaskHidePanel{})
|
||||||
type TaskHidePanel struct{}
|
type TaskHidePanel struct{}
|
||||||
|
|
||||||
type TaskSaveConfig struct{ Config map[string]any }
|
// TaskShowMessage shows a tray message or notification.
|
||||||
|
// Use: _, _, err := c.PERFORM(systray.TaskShowMessage{Title: "Core", Message: "Sync complete"})
|
||||||
|
type TaskShowMessage struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskSaveConfig persists this service's config section via the display orchestrator.
|
||||||
|
// Use: _, _, err := c.PERFORM(systray.TaskSaveConfig{Value: map[string]any{"tooltip": "Core"}})
|
||||||
|
type TaskSaveConfig struct{ Value map[string]any }
|
||||||
|
|
||||||
|
// --- Actions ---
|
||||||
|
|
||||||
|
// ActionTrayClicked is broadcast when the tray icon is clicked.
|
||||||
|
// Use: _ = c.ACTION(systray.ActionTrayClicked{})
|
||||||
type ActionTrayClicked struct{}
|
type ActionTrayClicked struct{}
|
||||||
|
|
||||||
|
// ActionTrayMenuItemClicked is broadcast when a tray menu item is clicked.
|
||||||
|
// Use: _ = c.ACTION(systray.ActionTrayMenuItemClicked{ActionID: "quit"})
|
||||||
type ActionTrayMenuItemClicked struct{ ActionID string }
|
type ActionTrayMenuItemClicked struct{ ActionID string }
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,20 @@
|
||||||
|
// pkg/systray/mock_platform.go
|
||||||
package systray
|
package systray
|
||||||
|
|
||||||
// MockPlatform is an exported mock for cross-package integration tests.
|
// MockPlatform is an exported mock for cross-package integration tests.
|
||||||
|
// Use: platform := systray.NewMockPlatform()
|
||||||
type MockPlatform struct{}
|
type MockPlatform struct{}
|
||||||
|
|
||||||
|
// NewMockPlatform creates a tray platform mock.
|
||||||
|
// Use: platform := systray.NewMockPlatform()
|
||||||
func NewMockPlatform() *MockPlatform { return &MockPlatform{} }
|
func NewMockPlatform() *MockPlatform { return &MockPlatform{} }
|
||||||
|
|
||||||
|
// NewTray creates a mock tray handle for tests.
|
||||||
|
// Use: tray := platform.NewTray()
|
||||||
func (m *MockPlatform) NewTray() PlatformTray { return &exportedMockTray{} }
|
func (m *MockPlatform) NewTray() PlatformTray { return &exportedMockTray{} }
|
||||||
|
|
||||||
|
// NewMenu creates a mock tray menu for tests.
|
||||||
|
// Use: menu := platform.NewMenu()
|
||||||
func (m *MockPlatform) NewMenu() PlatformMenu { return &exportedMockMenu{} }
|
func (m *MockPlatform) NewMenu() PlatformMenu { return &exportedMockMenu{} }
|
||||||
|
|
||||||
type exportedMockTray struct {
|
type exportedMockTray struct {
|
||||||
|
|
@ -22,7 +31,7 @@ func (t *exportedMockTray) AttachWindow(w WindowHandle) {}
|
||||||
|
|
||||||
type exportedMockMenu struct {
|
type exportedMockMenu struct {
|
||||||
items []exportedMockMenuItem
|
items []exportedMockMenuItem
|
||||||
subs []*exportedMockMenu
|
submenus []*exportedMockMenu
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *exportedMockMenu) Add(label string) PlatformMenuItem {
|
func (m *exportedMockMenu) Add(label string) PlatformMenuItem {
|
||||||
|
|
@ -32,9 +41,9 @@ func (m *exportedMockMenu) Add(label string) PlatformMenuItem {
|
||||||
}
|
}
|
||||||
func (m *exportedMockMenu) AddSeparator() {}
|
func (m *exportedMockMenu) AddSeparator() {}
|
||||||
func (m *exportedMockMenu) AddSubmenu(label string) PlatformMenu {
|
func (m *exportedMockMenu) AddSubmenu(label string) PlatformMenu {
|
||||||
m.items = append(m.items, exportedMockMenuItem{label: label})
|
|
||||||
sub := &exportedMockMenu{}
|
sub := &exportedMockMenu{}
|
||||||
m.subs = append(m.subs, sub)
|
m.items = append(m.items, exportedMockMenuItem{label: label})
|
||||||
|
m.submenus = append(m.submenus, sub)
|
||||||
return sub
|
return sub
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -48,3 +57,4 @@ func (mi *exportedMockMenuItem) SetTooltip(tip string) { mi.tooltip = tip }
|
||||||
func (mi *exportedMockMenuItem) SetChecked(checked bool) { mi.checked = checked }
|
func (mi *exportedMockMenuItem) SetChecked(checked bool) { mi.checked = checked }
|
||||||
func (mi *exportedMockMenuItem) SetEnabled(enabled bool) { mi.enabled = enabled }
|
func (mi *exportedMockMenuItem) SetEnabled(enabled bool) { mi.enabled = enabled }
|
||||||
func (mi *exportedMockMenuItem) OnClick(fn func()) { mi.onClick = fn }
|
func (mi *exportedMockMenuItem) OnClick(fn func()) { mi.onClick = fn }
|
||||||
|
func (mi *exportedMockMenuItem) AddSubmenu() PlatformMenu { return &exportedMockMenu{} }
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,7 @@ func (p *mockPlatform) NewMenu() PlatformMenu {
|
||||||
|
|
||||||
type mockTrayMenu struct {
|
type mockTrayMenu struct {
|
||||||
items []string
|
items []string
|
||||||
subs []*mockTrayMenu
|
submenus []*mockTrayMenu
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockTrayMenu) Add(label string) PlatformMenuItem {
|
func (m *mockTrayMenu) Add(label string) PlatformMenuItem {
|
||||||
|
|
@ -33,7 +33,7 @@ func (m *mockTrayMenu) AddSeparator() { m.items = append(m.items, "---") }
|
||||||
func (m *mockTrayMenu) AddSubmenu(label string) PlatformMenu {
|
func (m *mockTrayMenu) AddSubmenu(label string) PlatformMenu {
|
||||||
m.items = append(m.items, label)
|
m.items = append(m.items, label)
|
||||||
sub := &mockTrayMenu{}
|
sub := &mockTrayMenu{}
|
||||||
m.subs = append(m.subs, sub)
|
m.submenus = append(m.submenus, sub)
|
||||||
return sub
|
return sub
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,6 +43,7 @@ func (mi *mockTrayMenuItem) SetTooltip(text string) {}
|
||||||
func (mi *mockTrayMenuItem) SetChecked(checked bool) {}
|
func (mi *mockTrayMenuItem) SetChecked(checked bool) {}
|
||||||
func (mi *mockTrayMenuItem) SetEnabled(enabled bool) {}
|
func (mi *mockTrayMenuItem) SetEnabled(enabled bool) {}
|
||||||
func (mi *mockTrayMenuItem) OnClick(fn func()) {}
|
func (mi *mockTrayMenuItem) OnClick(fn func()) {}
|
||||||
|
func (mi *mockTrayMenuItem) AddSubmenu() PlatformMenu { return &mockTrayMenu{} }
|
||||||
|
|
||||||
type mockTray struct {
|
type mockTray struct {
|
||||||
icon, templateIcon []byte
|
icon, templateIcon []byte
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,14 @@
|
||||||
package systray
|
package systray
|
||||||
|
|
||||||
// Platform abstracts the system tray backend.
|
// Platform abstracts the system tray backend.
|
||||||
|
// Use: var p systray.Platform
|
||||||
type Platform interface {
|
type Platform interface {
|
||||||
NewTray() PlatformTray
|
NewTray() PlatformTray
|
||||||
NewMenu() PlatformMenu // Menu factory for building tray menus
|
NewMenu() PlatformMenu // Menu factory for building tray menus
|
||||||
}
|
}
|
||||||
|
|
||||||
// PlatformTray is a live tray handle from the backend.
|
// PlatformTray is a live tray handle from the backend.
|
||||||
|
// Use: var tray systray.PlatformTray
|
||||||
type PlatformTray interface {
|
type PlatformTray interface {
|
||||||
SetIcon(data []byte)
|
SetIcon(data []byte)
|
||||||
SetTemplateIcon(data []byte)
|
SetTemplateIcon(data []byte)
|
||||||
|
|
@ -18,6 +20,7 @@ type PlatformTray interface {
|
||||||
}
|
}
|
||||||
|
|
||||||
// PlatformMenu is a tray menu built by the backend.
|
// PlatformMenu is a tray menu built by the backend.
|
||||||
|
// Use: var menu systray.PlatformMenu
|
||||||
type PlatformMenu interface {
|
type PlatformMenu interface {
|
||||||
Add(label string) PlatformMenuItem
|
Add(label string) PlatformMenuItem
|
||||||
AddSeparator()
|
AddSeparator()
|
||||||
|
|
@ -25,16 +28,19 @@ type PlatformMenu interface {
|
||||||
}
|
}
|
||||||
|
|
||||||
// PlatformMenuItem is a single item in a tray menu.
|
// PlatformMenuItem is a single item in a tray menu.
|
||||||
|
// Use: var item systray.PlatformMenuItem
|
||||||
type PlatformMenuItem interface {
|
type PlatformMenuItem interface {
|
||||||
SetTooltip(text string)
|
SetTooltip(text string)
|
||||||
SetChecked(checked bool)
|
SetChecked(checked bool)
|
||||||
SetEnabled(enabled bool)
|
SetEnabled(enabled bool)
|
||||||
OnClick(fn func())
|
OnClick(fn func())
|
||||||
|
AddSubmenu() PlatformMenu
|
||||||
}
|
}
|
||||||
|
|
||||||
// WindowHandle is a cross-package interface for window operations.
|
// WindowHandle is a cross-package interface for window operations.
|
||||||
// Defined locally to avoid circular imports (display imports systray).
|
// Defined locally to avoid circular imports (display imports systray).
|
||||||
// pkg/window.PlatformWindow satisfies this implicitly.
|
// pkg/window.PlatformWindow satisfies this implicitly.
|
||||||
|
// Use: var w systray.WindowHandle
|
||||||
type WindowHandle interface {
|
type WindowHandle interface {
|
||||||
Name() string
|
Name() string
|
||||||
Show()
|
Show()
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
|
// pkg/systray/register.go
|
||||||
package systray
|
package systray
|
||||||
|
|
||||||
import "forge.lthn.ai/core/go/pkg/core"
|
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) {
|
func Register(p Platform) func(*core.Core) (any, error) {
|
||||||
return func(c *core.Core) (any, error) {
|
return func(c *core.Core) (any, error) {
|
||||||
return &Service{
|
return &Service{
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,19 @@
|
||||||
|
// pkg/systray/service.go
|
||||||
package systray
|
package systray
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
"forge.lthn.ai/core/go/pkg/core"
|
||||||
|
"forge.lthn.ai/core/gui/pkg/notification"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Options configures the systray service.
|
||||||
|
// Use: core.WithService(systray.Register(platform))
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
|
|
||||||
|
// Service manages system tray operations via Core tasks.
|
||||||
|
// Use: svc := &systray.Service{}
|
||||||
type Service struct {
|
type Service struct {
|
||||||
*core.ServiceRuntime[Options]
|
*core.ServiceRuntime[Options]
|
||||||
manager *Manager
|
manager *Manager
|
||||||
|
|
@ -15,31 +21,34 @@ type Service struct {
|
||||||
iconPath string
|
iconPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OnStartup loads tray config and registers task handlers.
|
||||||
|
// Use: _ = svc.OnStartup(context.Background())
|
||||||
func (s *Service) OnStartup(ctx context.Context) error {
|
func (s *Service) OnStartup(ctx context.Context) error {
|
||||||
configValue, handled, _ := s.Core().QUERY(QueryConfig{})
|
cfg, handled, _ := s.Core().QUERY(QueryConfig{})
|
||||||
if handled {
|
if handled {
|
||||||
if trayConfig, ok := configValue.(map[string]any); ok {
|
if tCfg, ok := cfg.(map[string]any); ok {
|
||||||
s.applyConfig(trayConfig)
|
s.applyConfig(tCfg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
s.Core().RegisterTask(s.handleTask)
|
s.Core().RegisterTask(s.handleTask)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) applyConfig(configData map[string]any) {
|
func (s *Service) applyConfig(cfg map[string]any) {
|
||||||
tooltip, _ := configData["tooltip"].(string)
|
tooltip, _ := cfg["tooltip"].(string)
|
||||||
if tooltip == "" {
|
if tooltip == "" {
|
||||||
tooltip = "Core"
|
tooltip = "Core"
|
||||||
}
|
}
|
||||||
_ = s.manager.Setup(tooltip, tooltip)
|
_ = s.manager.Setup(tooltip, tooltip)
|
||||||
|
|
||||||
if iconPath, ok := configData["icon"].(string); ok && iconPath != "" {
|
if iconPath, ok := cfg["icon"].(string); ok && iconPath != "" {
|
||||||
// Icon loading is deferred to when assets are available.
|
// Icon loading is deferred to when assets are available.
|
||||||
// Store the path for later use.
|
// Store the path for later use.
|
||||||
s.iconPath = iconPath
|
s.iconPath = iconPath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HandleIPCEvents satisfies Core's IPC hook.
|
||||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -48,14 +57,18 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
||||||
switch t := t.(type) {
|
switch t := t.(type) {
|
||||||
case TaskSetTrayIcon:
|
case TaskSetTrayIcon:
|
||||||
return nil, true, s.manager.SetIcon(t.Data)
|
return nil, true, s.manager.SetIcon(t.Data)
|
||||||
|
case TaskSetTooltip:
|
||||||
|
return nil, true, s.manager.SetTooltip(t.Tooltip)
|
||||||
|
case TaskSetLabel:
|
||||||
|
return nil, true, s.manager.SetLabel(t.Label)
|
||||||
case TaskSetTrayMenu:
|
case TaskSetTrayMenu:
|
||||||
return nil, true, s.taskSetTrayMenu(t)
|
return nil, true, s.taskSetTrayMenu(t)
|
||||||
case TaskShowPanel:
|
case TaskShowPanel:
|
||||||
// Panel show — deferred (requires WindowHandle integration)
|
return nil, true, s.manager.ShowPanel()
|
||||||
return nil, true, nil
|
|
||||||
case TaskHidePanel:
|
case TaskHidePanel:
|
||||||
// Panel hide — deferred (requires WindowHandle integration)
|
return nil, true, s.manager.HidePanel()
|
||||||
return nil, true, nil
|
case TaskShowMessage:
|
||||||
|
return nil, true, s.showTrayMessage(t.Title, t.Message)
|
||||||
default:
|
default:
|
||||||
return nil, false, nil
|
return nil, false, nil
|
||||||
}
|
}
|
||||||
|
|
@ -74,6 +87,29 @@ func (s *Service) taskSetTrayMenu(t TaskSetTrayMenu) error {
|
||||||
return s.manager.SetMenu(t.Items)
|
return s.manager.SetMenu(t.Items)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) showTrayMessage(title, message string) error {
|
||||||
|
if s.manager == nil || !s.manager.IsActive() {
|
||||||
|
_, _, err := s.Core().PERFORM(notification.TaskSend{
|
||||||
|
Opts: notification.NotificationOptions{Title: title, Message: message},
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tray := s.manager.Tray()
|
||||||
|
if tray == nil {
|
||||||
|
return core.E("systray.showTrayMessage", "tray not initialised", nil)
|
||||||
|
}
|
||||||
|
if messenger, ok := tray.(interface{ ShowMessage(title, message string) }); ok {
|
||||||
|
messenger.ShowMessage(title, message)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, _, err := s.Core().PERFORM(notification.TaskSend{
|
||||||
|
Opts: notification.NotificationOptions{Title: title, Message: message},
|
||||||
|
})
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager returns the underlying systray Manager.
|
||||||
|
// Use: manager := svc.Manager()
|
||||||
func (s *Service) Manager() *Manager {
|
func (s *Service) Manager() *Manager {
|
||||||
return s.manager
|
return s.manager
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,18 @@ import (
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type mockWindowHandle struct {
|
||||||
|
name string
|
||||||
|
showCalled bool
|
||||||
|
hideCalled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *mockWindowHandle) Name() string { return w.name }
|
||||||
|
func (w *mockWindowHandle) Show() { w.showCalled = true }
|
||||||
|
func (w *mockWindowHandle) Hide() { w.hideCalled = true }
|
||||||
|
func (w *mockWindowHandle) SetPosition(x, y int) {}
|
||||||
|
func (w *mockWindowHandle) SetSize(width, height int) {}
|
||||||
|
|
||||||
func newTestSystrayService(t *testing.T) (*Service, *core.Core) {
|
func newTestSystrayService(t *testing.T) (*Service, *core.Core) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
c, err := core.New(
|
c, err := core.New(
|
||||||
|
|
@ -39,6 +51,24 @@ func TestTaskSetTrayIcon_Good(t *testing.T) {
|
||||||
assert.True(t, handled)
|
assert.True(t, handled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTaskSetTooltip_Good(t *testing.T) {
|
||||||
|
svc, c := newTestSystrayService(t)
|
||||||
|
require.NoError(t, svc.manager.Setup("Test", "Test"))
|
||||||
|
|
||||||
|
_, handled, err := c.PERFORM(TaskSetTooltip{Tooltip: "Updated"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskSetLabel_Good(t *testing.T) {
|
||||||
|
svc, c := newTestSystrayService(t)
|
||||||
|
require.NoError(t, svc.manager.Setup("Test", "Test"))
|
||||||
|
|
||||||
|
_, handled, err := c.PERFORM(TaskSetLabel{Label: "Updated"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
}
|
||||||
|
|
||||||
func TestTaskSetTrayMenu_Good(t *testing.T) {
|
func TestTaskSetTrayMenu_Good(t *testing.T) {
|
||||||
svc, c := newTestSystrayService(t)
|
svc, c := newTestSystrayService(t)
|
||||||
|
|
||||||
|
|
@ -54,6 +84,33 @@ func TestTaskSetTrayMenu_Good(t *testing.T) {
|
||||||
assert.True(t, handled)
|
assert.True(t, handled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTaskSetTrayMenu_Submenu_Good(t *testing.T) {
|
||||||
|
p := newMockPlatform()
|
||||||
|
c, err := core.New(
|
||||||
|
core.WithService(Register(p)),
|
||||||
|
core.WithServiceLock(),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
||||||
|
|
||||||
|
svc := core.MustServiceFor[*Service](c, "systray")
|
||||||
|
require.NoError(t, svc.manager.Setup("Test", "Test"))
|
||||||
|
|
||||||
|
_, handled, err := c.PERFORM(TaskSetTrayMenu{Items: []TrayMenuItem{
|
||||||
|
{
|
||||||
|
Label: "File",
|
||||||
|
Submenu: []TrayMenuItem{
|
||||||
|
{Label: "Open", ActionID: "open"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
require.Len(t, p.trays, 1)
|
||||||
|
require.NotEmpty(t, p.menus)
|
||||||
|
require.Len(t, p.menus[0].submenus, 1)
|
||||||
|
}
|
||||||
|
|
||||||
func TestTaskSetTrayIcon_Bad(t *testing.T) {
|
func TestTaskSetTrayIcon_Bad(t *testing.T) {
|
||||||
// No systray service — PERFORM returns handled=false
|
// No systray service — PERFORM returns handled=false
|
||||||
c, err := core.New(core.WithServiceLock())
|
c, err := core.New(core.WithServiceLock())
|
||||||
|
|
@ -61,3 +118,29 @@ func TestTaskSetTrayIcon_Bad(t *testing.T) {
|
||||||
_, handled, _ := c.PERFORM(TaskSetTrayIcon{Data: nil})
|
_, handled, _ := c.PERFORM(TaskSetTrayIcon{Data: nil})
|
||||||
assert.False(t, handled)
|
assert.False(t, handled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTaskShowMessage_Good(t *testing.T) {
|
||||||
|
svc, c := newTestSystrayService(t)
|
||||||
|
require.NoError(t, svc.manager.Setup("Test", "Test"))
|
||||||
|
_, handled, err := c.PERFORM(TaskShowMessage{Title: "Hello", Message: "World"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskShowHidePanel_Good(t *testing.T) {
|
||||||
|
svc, c := newTestSystrayService(t)
|
||||||
|
require.NoError(t, svc.manager.Setup("Test", "Test"))
|
||||||
|
|
||||||
|
panel := &mockWindowHandle{name: "panel"}
|
||||||
|
require.NoError(t, svc.manager.AttachWindow(panel))
|
||||||
|
|
||||||
|
_, handled, err := c.PERFORM(TaskShowPanel{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
assert.True(t, panel.showCalled)
|
||||||
|
|
||||||
|
_, handled, err = c.PERFORM(TaskHidePanel{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
assert.True(t, panel.hideCalled)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,22 +5,29 @@ import (
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
"forge.lthn.ai/core/go/pkg/core"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed assets/apptray.png
|
//go:embed assets/apptray.png
|
||||||
var defaultIcon []byte
|
var defaultIcon []byte
|
||||||
|
|
||||||
// Manager manages the system tray lifecycle.
|
// Manager manages the system tray lifecycle.
|
||||||
// State that was previously in package-level vars is now on the Manager.
|
// Use: manager := systray.NewManager(platform)
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
platform Platform
|
platform Platform
|
||||||
tray PlatformTray
|
tray PlatformTray
|
||||||
|
panelWindow WindowHandle
|
||||||
callbacks map[string]func()
|
callbacks map[string]func()
|
||||||
|
tooltip string
|
||||||
|
label string
|
||||||
|
hasIcon bool
|
||||||
|
hasTemplateIcon bool
|
||||||
|
menuItems []TrayMenuItem
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewManager creates a systray Manager.
|
// NewManager creates a systray Manager.
|
||||||
|
// Use: manager := systray.NewManager(platform)
|
||||||
func NewManager(platform Platform) *Manager {
|
func NewManager(platform Platform) *Manager {
|
||||||
return &Manager{
|
return &Manager{
|
||||||
platform: platform,
|
platform: platform,
|
||||||
|
|
@ -29,68 +36,112 @@ func NewManager(platform Platform) *Manager {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Setup creates the system tray with default icon and tooltip.
|
// Setup creates the system tray with default icon and tooltip.
|
||||||
|
// Use: _ = manager.Setup("Core", "Core")
|
||||||
func (m *Manager) Setup(tooltip, label string) error {
|
func (m *Manager) Setup(tooltip, label string) error {
|
||||||
m.tray = m.platform.NewTray()
|
m.tray = m.platform.NewTray()
|
||||||
if m.tray == nil {
|
if m.tray == nil {
|
||||||
return coreerr.E("systray.Setup", "platform returned nil tray", nil)
|
return core.E("systray.Setup", "platform returned nil tray", nil)
|
||||||
}
|
}
|
||||||
m.tray.SetTemplateIcon(defaultIcon)
|
m.tray.SetTemplateIcon(defaultIcon)
|
||||||
m.tray.SetTooltip(tooltip)
|
m.tray.SetTooltip(tooltip)
|
||||||
m.tray.SetLabel(label)
|
m.tray.SetLabel(label)
|
||||||
|
m.tooltip = tooltip
|
||||||
|
m.label = label
|
||||||
|
m.hasTemplateIcon = true
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetIcon sets the tray icon.
|
// SetIcon sets the tray icon.
|
||||||
|
// Use: _ = manager.SetIcon(iconBytes)
|
||||||
func (m *Manager) SetIcon(data []byte) error {
|
func (m *Manager) SetIcon(data []byte) error {
|
||||||
if m.tray == nil {
|
if m.tray == nil {
|
||||||
return coreerr.E("systray.SetIcon", "tray not initialised", nil)
|
return core.E("systray.SetIcon", "tray not initialised", nil)
|
||||||
}
|
}
|
||||||
m.tray.SetIcon(data)
|
m.tray.SetIcon(data)
|
||||||
|
m.hasIcon = len(data) > 0
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetTemplateIcon sets the template icon (macOS).
|
// SetTemplateIcon sets the template icon (macOS).
|
||||||
|
// Use: _ = manager.SetTemplateIcon(iconBytes)
|
||||||
func (m *Manager) SetTemplateIcon(data []byte) error {
|
func (m *Manager) SetTemplateIcon(data []byte) error {
|
||||||
if m.tray == nil {
|
if m.tray == nil {
|
||||||
return coreerr.E("systray.SetTemplateIcon", "tray not initialised", nil)
|
return core.E("systray.SetTemplateIcon", "tray not initialised", nil)
|
||||||
}
|
}
|
||||||
m.tray.SetTemplateIcon(data)
|
m.tray.SetTemplateIcon(data)
|
||||||
|
m.hasTemplateIcon = len(data) > 0
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetTooltip sets the tray tooltip.
|
// SetTooltip sets the tray tooltip.
|
||||||
|
// Use: _ = manager.SetTooltip("Core is ready")
|
||||||
func (m *Manager) SetTooltip(text string) error {
|
func (m *Manager) SetTooltip(text string) error {
|
||||||
if m.tray == nil {
|
if m.tray == nil {
|
||||||
return coreerr.E("systray.SetTooltip", "tray not initialised", nil)
|
return core.E("systray.SetTooltip", "tray not initialised", nil)
|
||||||
}
|
}
|
||||||
m.tray.SetTooltip(text)
|
m.tray.SetTooltip(text)
|
||||||
|
m.tooltip = text
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetLabel sets the tray label.
|
// SetLabel sets the tray label.
|
||||||
|
// Use: _ = manager.SetLabel("Core")
|
||||||
func (m *Manager) SetLabel(text string) error {
|
func (m *Manager) SetLabel(text string) error {
|
||||||
if m.tray == nil {
|
if m.tray == nil {
|
||||||
return coreerr.E("systray.SetLabel", "tray not initialised", nil)
|
return core.E("systray.SetLabel", "tray not initialised", nil)
|
||||||
}
|
}
|
||||||
m.tray.SetLabel(text)
|
m.tray.SetLabel(text)
|
||||||
|
m.label = text
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// AttachWindow attaches a panel window to the tray.
|
// AttachWindow attaches a panel window to the tray.
|
||||||
|
// Use: _ = manager.AttachWindow(windowHandle)
|
||||||
func (m *Manager) AttachWindow(w WindowHandle) error {
|
func (m *Manager) AttachWindow(w WindowHandle) error {
|
||||||
if m.tray == nil {
|
if m.tray == nil {
|
||||||
return coreerr.E("systray.AttachWindow", "tray not initialised", nil)
|
return core.E("systray.AttachWindow", "tray not initialised", nil)
|
||||||
}
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
m.panelWindow = w
|
||||||
|
m.mu.Unlock()
|
||||||
m.tray.AttachWindow(w)
|
m.tray.AttachWindow(w)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ShowPanel shows the attached tray panel window if one is configured.
|
||||||
|
// Use: _ = manager.ShowPanel()
|
||||||
|
func (m *Manager) ShowPanel() error {
|
||||||
|
m.mu.RLock()
|
||||||
|
w := m.panelWindow
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if w == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
w.Show()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// HidePanel hides the attached tray panel window if one is configured.
|
||||||
|
// Use: _ = manager.HidePanel()
|
||||||
|
func (m *Manager) HidePanel() error {
|
||||||
|
m.mu.RLock()
|
||||||
|
w := m.panelWindow
|
||||||
|
m.mu.RUnlock()
|
||||||
|
if w == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
w.Hide()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Tray returns the underlying platform tray for direct access.
|
// Tray returns the underlying platform tray for direct access.
|
||||||
|
// Use: tray := manager.Tray()
|
||||||
func (m *Manager) Tray() PlatformTray {
|
func (m *Manager) Tray() PlatformTray {
|
||||||
return m.tray
|
return m.tray
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsActive returns whether a tray has been created.
|
// IsActive returns whether a tray has been created.
|
||||||
|
// Use: active := manager.IsActive()
|
||||||
func (m *Manager) IsActive() bool {
|
func (m *Manager) IsActive() bool {
|
||||||
return m.tray != nil
|
return m.tray != nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,29 +84,3 @@ func TestManager_GetInfo_Good(t *testing.T) {
|
||||||
info = m.GetInfo()
|
info = m.GetInfo()
|
||||||
assert.True(t, info["active"].(bool))
|
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])
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -6,25 +6,31 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// WailsPlatform implements Platform using Wails v3.
|
// WailsPlatform implements Platform using Wails v3.
|
||||||
|
// Use: platform := systray.NewWailsPlatform(app)
|
||||||
type WailsPlatform struct {
|
type WailsPlatform struct {
|
||||||
app *application.App
|
app *application.App
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewWailsPlatform creates a Wails-backed tray platform.
|
||||||
|
// Use: platform := systray.NewWailsPlatform(app)
|
||||||
func NewWailsPlatform(app *application.App) *WailsPlatform {
|
func NewWailsPlatform(app *application.App) *WailsPlatform {
|
||||||
return &WailsPlatform{app: app}
|
return &WailsPlatform{app: app}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewTray creates a Wails system tray handle.
|
||||||
|
// Use: tray := platform.NewTray()
|
||||||
func (wp *WailsPlatform) NewTray() PlatformTray {
|
func (wp *WailsPlatform) NewTray() PlatformTray {
|
||||||
return &wailsTray{tray: wp.app.SystemTray.New(), app: wp.app}
|
return &wailsTray{tray: wp.app.SystemTray.New()}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewMenu creates a Wails tray menu handle.
|
||||||
|
// Use: menu := platform.NewMenu()
|
||||||
func (wp *WailsPlatform) NewMenu() PlatformMenu {
|
func (wp *WailsPlatform) NewMenu() PlatformMenu {
|
||||||
return &wailsTrayMenu{menu: wp.app.NewMenu()}
|
return &wailsTrayMenu{menu: wp.app.NewMenu()}
|
||||||
}
|
}
|
||||||
|
|
||||||
type wailsTray struct {
|
type wailsTray struct {
|
||||||
tray *application.SystemTray
|
tray *application.SystemTray
|
||||||
app *application.App
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (wt *wailsTray) SetIcon(data []byte) { wt.tray.SetIcon(data) }
|
func (wt *wailsTray) SetIcon(data []byte) { wt.tray.SetIcon(data) }
|
||||||
|
|
@ -39,8 +45,19 @@ func (wt *wailsTray) SetMenu(menu PlatformMenu) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (wt *wailsTray) AttachWindow(w WindowHandle) {
|
func (wt *wailsTray) AttachWindow(w WindowHandle) {
|
||||||
// Wails systray AttachWindow expects an application.Window interface.
|
if wt.tray == nil {
|
||||||
// The caller must pass an appropriate wrapper.
|
return
|
||||||
|
}
|
||||||
|
window, ok := w.(interface {
|
||||||
|
Show()
|
||||||
|
Hide()
|
||||||
|
Focus()
|
||||||
|
IsVisible() bool
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
wt.tray.AttachWindow(window)
|
||||||
}
|
}
|
||||||
|
|
||||||
// wailsTrayMenu wraps *application.Menu for the PlatformMenu interface.
|
// wailsTrayMenu wraps *application.Menu for the PlatformMenu interface.
|
||||||
|
|
@ -71,3 +88,7 @@ func (mi *wailsTrayMenuItem) SetEnabled(enabled bool) { mi.item.SetEnabled(enabl
|
||||||
func (mi *wailsTrayMenuItem) OnClick(fn func()) {
|
func (mi *wailsTrayMenuItem) OnClick(fn func()) {
|
||||||
mi.item.OnClick(func(ctx *application.Context) { fn() })
|
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()}
|
||||||
|
}
|
||||||
|
|
|
||||||
34
pkg/systray/wails_test.go
Normal file
34
pkg/systray/wails_test.go
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
package systray
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/wailsapp/wails/v3/pkg/application"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWailsTray_AttachWindow_Good(t *testing.T) {
|
||||||
|
app := application.NewApp()
|
||||||
|
platform := NewWailsPlatform(app)
|
||||||
|
|
||||||
|
tray, ok := platform.NewTray().(*wailsTray)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||||
|
Name: "panel",
|
||||||
|
Title: "Panel",
|
||||||
|
Hidden: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
tray.AttachWindow(window)
|
||||||
|
|
||||||
|
assert.False(t, window.IsVisible())
|
||||||
|
|
||||||
|
tray.tray.Click()
|
||||||
|
assert.True(t, window.IsVisible())
|
||||||
|
assert.True(t, window.IsFocused())
|
||||||
|
|
||||||
|
tray.tray.Click()
|
||||||
|
assert.False(t, window.IsVisible())
|
||||||
|
}
|
||||||
158
pkg/webview/diagnostics.go
Normal file
158
pkg/webview/diagnostics.go
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
// pkg/webview/diagnostics.go
|
||||||
|
package webview
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func jsQuote(v string) string {
|
||||||
|
b, _ := json.Marshal(v)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func computedStyleScript(selector string) string {
|
||||||
|
sel := jsQuote(selector)
|
||||||
|
return fmt.Sprintf(`(function(){
|
||||||
|
const el = document.querySelector(%s);
|
||||||
|
if (!el) return null;
|
||||||
|
const style = window.getComputedStyle(el);
|
||||||
|
const out = {};
|
||||||
|
for (let i = 0; i < style.length; i++) {
|
||||||
|
const key = style[i];
|
||||||
|
out[key] = style.getPropertyValue(key);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
})()`, sel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func highlightScript(selector, colour string) string {
|
||||||
|
sel := jsQuote(selector)
|
||||||
|
if colour == "" {
|
||||||
|
colour = "#ff9800"
|
||||||
|
}
|
||||||
|
col := jsQuote(colour)
|
||||||
|
return fmt.Sprintf(`(function(){
|
||||||
|
const el = document.querySelector(%s);
|
||||||
|
if (!el) return false;
|
||||||
|
if (el.__coreHighlightOrigOutline === undefined) {
|
||||||
|
el.__coreHighlightOrigOutline = el.style.outline || "";
|
||||||
|
}
|
||||||
|
el.style.outline = "3px solid " + %s;
|
||||||
|
el.style.outlineOffset = "2px";
|
||||||
|
try { el.scrollIntoView({block: "center", inline: "center", behavior: "smooth"}); } catch (e) {}
|
||||||
|
return true;
|
||||||
|
})()`, sel, col)
|
||||||
|
}
|
||||||
|
|
||||||
|
func performanceScript() string {
|
||||||
|
return `(function(){
|
||||||
|
const nav = performance.getEntriesByType("navigation")[0] || {};
|
||||||
|
const paints = performance.getEntriesByType("paint");
|
||||||
|
const firstPaint = paints.find((entry) => entry.name === "first-paint");
|
||||||
|
const firstContentfulPaint = paints.find((entry) => entry.name === "first-contentful-paint");
|
||||||
|
const memory = performance.memory || {};
|
||||||
|
return {
|
||||||
|
navigationStart: nav.startTime || 0,
|
||||||
|
domContentLoaded: nav.domContentLoadedEventEnd || 0,
|
||||||
|
loadEventEnd: nav.loadEventEnd || 0,
|
||||||
|
firstPaint: firstPaint ? firstPaint.startTime : 0,
|
||||||
|
firstContentfulPaint: firstContentfulPaint ? firstContentfulPaint.startTime : 0,
|
||||||
|
usedJSHeapSize: memory.usedJSHeapSize || 0,
|
||||||
|
totalJSHeapSize: memory.totalJSHeapSize || 0
|
||||||
|
};
|
||||||
|
})()`
|
||||||
|
}
|
||||||
|
|
||||||
|
func resourcesScript() string {
|
||||||
|
return `(function(){
|
||||||
|
return performance.getEntriesByType("resource").map((entry) => ({
|
||||||
|
name: entry.name,
|
||||||
|
entryType: entry.entryType,
|
||||||
|
initiatorType: entry.initiatorType,
|
||||||
|
startTime: entry.startTime,
|
||||||
|
duration: entry.duration,
|
||||||
|
transferSize: entry.transferSize || 0,
|
||||||
|
encodedBodySize: entry.encodedBodySize || 0,
|
||||||
|
decodedBodySize: entry.decodedBodySize || 0
|
||||||
|
}));
|
||||||
|
})()`
|
||||||
|
}
|
||||||
|
|
||||||
|
func networkInitScript() string {
|
||||||
|
return `(function(){
|
||||||
|
if (window.__coreNetworkLog) return true;
|
||||||
|
window.__coreNetworkLog = [];
|
||||||
|
const log = (entry) => { window.__coreNetworkLog.push(entry); };
|
||||||
|
const originalFetch = window.fetch;
|
||||||
|
if (originalFetch) {
|
||||||
|
window.fetch = async function(input, init) {
|
||||||
|
const request = typeof input === "string" ? input : (input && input.url) ? input.url : "";
|
||||||
|
const method = (init && init.method) || (input && input.method) || "GET";
|
||||||
|
const started = Date.now();
|
||||||
|
try {
|
||||||
|
const response = await originalFetch.call(this, input, init);
|
||||||
|
log({
|
||||||
|
url: response.url || request,
|
||||||
|
method: method,
|
||||||
|
status: response.status,
|
||||||
|
ok: response.ok,
|
||||||
|
resource: "fetch",
|
||||||
|
timestamp: started
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
} catch (error) {
|
||||||
|
log({
|
||||||
|
url: request,
|
||||||
|
method: method,
|
||||||
|
error: String(error),
|
||||||
|
resource: "fetch",
|
||||||
|
timestamp: started
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const originalOpen = XMLHttpRequest.prototype.open;
|
||||||
|
const originalSend = XMLHttpRequest.prototype.send;
|
||||||
|
XMLHttpRequest.prototype.open = function(method, url) {
|
||||||
|
this.__coreMethod = method;
|
||||||
|
this.__coreUrl = url;
|
||||||
|
return originalOpen.apply(this, arguments);
|
||||||
|
};
|
||||||
|
XMLHttpRequest.prototype.send = function(body) {
|
||||||
|
const started = Date.now();
|
||||||
|
this.addEventListener("loadend", () => {
|
||||||
|
log({
|
||||||
|
url: this.__coreUrl || "",
|
||||||
|
method: this.__coreMethod || "GET",
|
||||||
|
status: this.status || 0,
|
||||||
|
ok: this.status >= 200 && this.status < 400,
|
||||||
|
resource: "xhr",
|
||||||
|
timestamp: started
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return originalSend.apply(this, arguments);
|
||||||
|
};
|
||||||
|
return true;
|
||||||
|
})()`
|
||||||
|
}
|
||||||
|
|
||||||
|
func networkClearScript() string {
|
||||||
|
return `(function(){
|
||||||
|
window.__coreNetworkLog = [];
|
||||||
|
return true;
|
||||||
|
})()`
|
||||||
|
}
|
||||||
|
|
||||||
|
func networkLogScript(limit int) string {
|
||||||
|
if limit <= 0 {
|
||||||
|
return `(window.__coreNetworkLog || [])`
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(`(window.__coreNetworkLog || []).slice(-%d)`, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeWhitespace(s string) string {
|
||||||
|
return strings.TrimSpace(s)
|
||||||
|
}
|
||||||
|
|
@ -6,12 +6,19 @@ import "time"
|
||||||
// --- Queries (read-only) ---
|
// --- Queries (read-only) ---
|
||||||
|
|
||||||
// QueryURL gets the current page URL. Result: string
|
// QueryURL gets the current page URL. Result: string
|
||||||
type QueryURL struct{ Window string `json:"window"` }
|
// Use: result, _, err := c.QUERY(webview.QueryURL{Window: "editor"})
|
||||||
|
type QueryURL struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
}
|
||||||
|
|
||||||
// QueryTitle gets the current page title. Result: string
|
// QueryTitle gets the current page title. Result: string
|
||||||
type QueryTitle struct{ Window string `json:"window"` }
|
// Use: result, _, err := c.QUERY(webview.QueryTitle{Window: "editor"})
|
||||||
|
type QueryTitle struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
}
|
||||||
|
|
||||||
// QueryConsole gets captured console messages. Result: []ConsoleMessage
|
// QueryConsole gets captured console messages. Result: []ConsoleMessage
|
||||||
|
// Use: result, _, err := c.QUERY(webview.QueryConsole{Window: "editor", Level: "error", Limit: 20})
|
||||||
type QueryConsole struct {
|
type QueryConsole struct {
|
||||||
Window string `json:"window"`
|
Window string `json:"window"`
|
||||||
Level string `json:"level,omitempty"` // filter by type: "log", "warn", "error", "info", "debug"
|
Level string `json:"level,omitempty"` // filter by type: "log", "warn", "error", "info", "debug"
|
||||||
|
|
@ -19,38 +26,77 @@ type QueryConsole struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// QuerySelector finds a single element. Result: *ElementInfo (nil if not found)
|
// QuerySelector finds a single element. Result: *ElementInfo (nil if not found)
|
||||||
|
// Use: result, _, err := c.QUERY(webview.QuerySelector{Window: "editor", Selector: "#submit"})
|
||||||
type QuerySelector struct {
|
type QuerySelector struct {
|
||||||
Window string `json:"window"`
|
Window string `json:"window"`
|
||||||
Selector string `json:"selector"`
|
Selector string `json:"selector"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// QuerySelectorAll finds all matching elements. Result: []*ElementInfo
|
// QuerySelectorAll finds all matching elements. Result: []*ElementInfo
|
||||||
|
// Use: result, _, err := c.QUERY(webview.QuerySelectorAll{Window: "editor", Selector: "button"})
|
||||||
type QuerySelectorAll struct {
|
type QuerySelectorAll struct {
|
||||||
Window string `json:"window"`
|
Window string `json:"window"`
|
||||||
Selector string `json:"selector"`
|
Selector string `json:"selector"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// QueryDOMTree gets HTML content. Result: string (outerHTML)
|
// QueryDOMTree gets HTML content. Result: string (outerHTML)
|
||||||
|
// Use: result, _, err := c.QUERY(webview.QueryDOMTree{Window: "editor", Selector: "main"})
|
||||||
type QueryDOMTree struct {
|
type QueryDOMTree struct {
|
||||||
Window string `json:"window"`
|
Window string `json:"window"`
|
||||||
Selector string `json:"selector,omitempty"` // empty = full document
|
Selector string `json:"selector,omitempty"` // empty = full document
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QueryComputedStyle returns the computed CSS properties for an element.
|
||||||
|
// Use: result, _, err := c.QUERY(webview.QueryComputedStyle{Window: "editor", Selector: "#panel"})
|
||||||
|
type QueryComputedStyle struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
Selector string `json:"selector"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryPerformance returns page performance metrics.
|
||||||
|
// Use: result, _, err := c.QUERY(webview.QueryPerformance{Window: "editor"})
|
||||||
|
type QueryPerformance struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryResources returns the page's loaded resource entries.
|
||||||
|
// Use: result, _, err := c.QUERY(webview.QueryResources{Window: "editor"})
|
||||||
|
type QueryResources struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryNetwork returns the captured network log.
|
||||||
|
// Use: result, _, err := c.QUERY(webview.QueryNetwork{Window: "editor", Limit: 50})
|
||||||
|
type QueryNetwork struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryExceptions returns captured JavaScript exceptions.
|
||||||
|
// Use: result, _, err := c.QUERY(webview.QueryExceptions{Window: "editor", Limit: 10})
|
||||||
|
type QueryExceptions struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
Limit int `json:"limit,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// --- Tasks (side-effects) ---
|
// --- Tasks (side-effects) ---
|
||||||
|
|
||||||
// TaskEvaluate executes JavaScript. Result: any (JS return value)
|
// TaskEvaluate executes JavaScript. Result: any (JS return value)
|
||||||
|
// Use: _, _, err := c.PERFORM(webview.TaskEvaluate{Window: "editor", Script: "document.title"})
|
||||||
type TaskEvaluate struct {
|
type TaskEvaluate struct {
|
||||||
Window string `json:"window"`
|
Window string `json:"window"`
|
||||||
Script string `json:"script"`
|
Script string `json:"script"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TaskClick clicks an element. Result: nil
|
// TaskClick clicks an element. Result: nil
|
||||||
|
// Use: _, _, err := c.PERFORM(webview.TaskClick{Window: "editor", Selector: "#submit"})
|
||||||
type TaskClick struct {
|
type TaskClick struct {
|
||||||
Window string `json:"window"`
|
Window string `json:"window"`
|
||||||
Selector string `json:"selector"`
|
Selector string `json:"selector"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TaskType types text into an element. Result: nil
|
// TaskType types text into an element. Result: nil
|
||||||
|
// Use: _, _, err := c.PERFORM(webview.TaskType{Window: "editor", Selector: "#search", Text: "core"})
|
||||||
type TaskType struct {
|
type TaskType struct {
|
||||||
Window string `json:"window"`
|
Window string `json:"window"`
|
||||||
Selector string `json:"selector"`
|
Selector string `json:"selector"`
|
||||||
|
|
@ -58,15 +104,27 @@ type TaskType struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TaskNavigate navigates to a URL. Result: nil
|
// TaskNavigate navigates to a URL. Result: nil
|
||||||
|
// Use: _, _, err := c.PERFORM(webview.TaskNavigate{Window: "editor", URL: "https://example.com"})
|
||||||
type TaskNavigate struct {
|
type TaskNavigate struct {
|
||||||
Window string `json:"window"`
|
Window string `json:"window"`
|
||||||
URL string `json:"url"`
|
URL string `json:"url"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TaskScreenshot captures the page as PNG. Result: ScreenshotResult
|
// TaskScreenshot captures the page as PNG. Result: ScreenshotResult
|
||||||
type TaskScreenshot struct{ Window string `json:"window"` }
|
// Use: result, _, err := c.PERFORM(webview.TaskScreenshot{Window: "editor"})
|
||||||
|
type TaskScreenshot struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskScreenshotElement captures a specific element as PNG. Result: ScreenshotResult
|
||||||
|
// Use: result, _, err := c.PERFORM(webview.TaskScreenshotElement{Window: "editor", Selector: "#panel"})
|
||||||
|
type TaskScreenshotElement struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
Selector string `json:"selector"`
|
||||||
|
}
|
||||||
|
|
||||||
// TaskScroll scrolls to an absolute position (window.scrollTo). Result: nil
|
// TaskScroll scrolls to an absolute position (window.scrollTo). Result: nil
|
||||||
|
// Use: _, _, err := c.PERFORM(webview.TaskScroll{Window: "editor", X: 0, Y: 600})
|
||||||
type TaskScroll struct {
|
type TaskScroll struct {
|
||||||
Window string `json:"window"`
|
Window string `json:"window"`
|
||||||
X int `json:"x"`
|
X int `json:"x"`
|
||||||
|
|
@ -74,12 +132,14 @@ type TaskScroll struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TaskHover hovers over an element. Result: nil
|
// TaskHover hovers over an element. Result: nil
|
||||||
|
// Use: _, _, err := c.PERFORM(webview.TaskHover{Window: "editor", Selector: "#help"})
|
||||||
type TaskHover struct {
|
type TaskHover struct {
|
||||||
Window string `json:"window"`
|
Window string `json:"window"`
|
||||||
Selector string `json:"selector"`
|
Selector string `json:"selector"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TaskSelect selects an option in a <select> element. Result: nil
|
// TaskSelect selects an option in a <select> element. Result: nil
|
||||||
|
// Use: _, _, err := c.PERFORM(webview.TaskSelect{Window: "editor", Selector: "#theme", Value: "dark"})
|
||||||
type TaskSelect struct {
|
type TaskSelect struct {
|
||||||
Window string `json:"window"`
|
Window string `json:"window"`
|
||||||
Selector string `json:"selector"`
|
Selector string `json:"selector"`
|
||||||
|
|
@ -87,6 +147,7 @@ type TaskSelect struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TaskCheck checks/unchecks a checkbox. Result: nil
|
// TaskCheck checks/unchecks a checkbox. Result: nil
|
||||||
|
// Use: _, _, err := c.PERFORM(webview.TaskCheck{Window: "editor", Selector: "#accept", Checked: true})
|
||||||
type TaskCheck struct {
|
type TaskCheck struct {
|
||||||
Window string `json:"window"`
|
Window string `json:"window"`
|
||||||
Selector string `json:"selector"`
|
Selector string `json:"selector"`
|
||||||
|
|
@ -94,6 +155,7 @@ type TaskCheck struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TaskUploadFile uploads files to an input element. Result: nil
|
// TaskUploadFile uploads files to an input element. Result: nil
|
||||||
|
// Use: _, _, err := c.PERFORM(webview.TaskUploadFile{Window: "editor", Selector: "input[type=file]", Paths: []string{"/tmp/report.pdf"}})
|
||||||
type TaskUploadFile struct {
|
type TaskUploadFile struct {
|
||||||
Window string `json:"window"`
|
Window string `json:"window"`
|
||||||
Selector string `json:"selector"`
|
Selector string `json:"selector"`
|
||||||
|
|
@ -101,6 +163,7 @@ type TaskUploadFile struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TaskSetViewport sets the viewport dimensions. Result: nil
|
// TaskSetViewport sets the viewport dimensions. Result: nil
|
||||||
|
// Use: _, _, err := c.PERFORM(webview.TaskSetViewport{Window: "editor", Width: 1280, Height: 800})
|
||||||
type TaskSetViewport struct {
|
type TaskSetViewport struct {
|
||||||
Window string `json:"window"`
|
Window string `json:"window"`
|
||||||
Width int `json:"width"`
|
Width int `json:"width"`
|
||||||
|
|
@ -108,17 +171,66 @@ type TaskSetViewport struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// TaskClearConsole clears captured console messages. Result: nil
|
// TaskClearConsole clears captured console messages. Result: nil
|
||||||
type TaskClearConsole struct{ Window string `json:"window"` }
|
// Use: _, _, err := c.PERFORM(webview.TaskClearConsole{Window: "editor"})
|
||||||
|
type TaskClearConsole struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskHighlight visually highlights an element.
|
||||||
|
// Use: _, _, err := c.PERFORM(webview.TaskHighlight{Window: "editor", Selector: "#submit", Colour: "#ffcc00"})
|
||||||
|
type TaskHighlight struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
Selector string `json:"selector"`
|
||||||
|
Colour string `json:"colour,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskOpenDevTools opens the browser devtools for the target window. Result: nil
|
||||||
|
// Use: _, _, err := c.PERFORM(webview.TaskOpenDevTools{Window: "editor"})
|
||||||
|
type TaskOpenDevTools struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskCloseDevTools closes the browser devtools for the target window. Result: nil
|
||||||
|
// Use: _, _, err := c.PERFORM(webview.TaskCloseDevTools{Window: "editor"})
|
||||||
|
type TaskCloseDevTools struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskInjectNetworkLogging injects fetch/XHR interception into the page.
|
||||||
|
// Use: _, _, err := c.PERFORM(webview.TaskInjectNetworkLogging{Window: "editor"})
|
||||||
|
type TaskInjectNetworkLogging struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskClearNetworkLog clears the captured network log.
|
||||||
|
// Use: _, _, err := c.PERFORM(webview.TaskClearNetworkLog{Window: "editor"})
|
||||||
|
type TaskClearNetworkLog struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskPrint prints the current page using the browser's native print flow.
|
||||||
|
// Use: _, _, err := c.PERFORM(webview.TaskPrint{Window: "editor"})
|
||||||
|
type TaskPrint struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskExportPDF exports the page to a PDF document.
|
||||||
|
// Use: result, _, err := c.PERFORM(webview.TaskExportPDF{Window: "editor"})
|
||||||
|
type TaskExportPDF struct {
|
||||||
|
Window string `json:"window"`
|
||||||
|
}
|
||||||
|
|
||||||
// --- Actions (broadcast) ---
|
// --- Actions (broadcast) ---
|
||||||
|
|
||||||
// ActionConsoleMessage is broadcast when a console message is captured.
|
// ActionConsoleMessage is broadcast when a console message is captured.
|
||||||
|
// Use: _ = c.ACTION(webview.ActionConsoleMessage{Window: "editor", Message: webview.ConsoleMessage{Type: "error", Text: "boom"}})
|
||||||
type ActionConsoleMessage struct {
|
type ActionConsoleMessage struct {
|
||||||
Window string `json:"window"`
|
Window string `json:"window"`
|
||||||
Message ConsoleMessage `json:"message"`
|
Message ConsoleMessage `json:"message"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActionException is broadcast when a JavaScript exception occurs.
|
// ActionException is broadcast when a JavaScript exception occurs.
|
||||||
|
// Use: _ = c.ACTION(webview.ActionException{Window: "editor", Exception: webview.ExceptionInfo{Text: "ReferenceError"}})
|
||||||
type ActionException struct {
|
type ActionException struct {
|
||||||
Window string `json:"window"`
|
Window string `json:"window"`
|
||||||
Exception ExceptionInfo `json:"exception"`
|
Exception ExceptionInfo `json:"exception"`
|
||||||
|
|
@ -127,6 +239,7 @@ type ActionException struct {
|
||||||
// --- Types ---
|
// --- Types ---
|
||||||
|
|
||||||
// ConsoleMessage represents a browser console message.
|
// ConsoleMessage represents a browser console message.
|
||||||
|
// Use: msg := webview.ConsoleMessage{Type: "warn", Text: "slow network"}
|
||||||
type ConsoleMessage struct {
|
type ConsoleMessage struct {
|
||||||
Type string `json:"type"` // "log", "warn", "error", "info", "debug"
|
Type string `json:"type"` // "log", "warn", "error", "info", "debug"
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
|
|
@ -137,6 +250,7 @@ type ConsoleMessage struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ElementInfo represents a DOM element.
|
// ElementInfo represents a DOM element.
|
||||||
|
// Use: el := webview.ElementInfo{TagName: "button", InnerText: "Save"}
|
||||||
type ElementInfo struct {
|
type ElementInfo struct {
|
||||||
TagName string `json:"tagName"`
|
TagName string `json:"tagName"`
|
||||||
Attributes map[string]string `json:"attributes,omitempty"`
|
Attributes map[string]string `json:"attributes,omitempty"`
|
||||||
|
|
@ -146,6 +260,7 @@ type ElementInfo struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// BoundingBox represents element position and size.
|
// BoundingBox represents element position and size.
|
||||||
|
// Use: box := webview.BoundingBox{X: 10, Y: 20, Width: 120, Height: 40}
|
||||||
type BoundingBox struct {
|
type BoundingBox struct {
|
||||||
X float64 `json:"x"`
|
X float64 `json:"x"`
|
||||||
Y float64 `json:"y"`
|
Y float64 `json:"y"`
|
||||||
|
|
@ -155,6 +270,7 @@ type BoundingBox struct {
|
||||||
|
|
||||||
// ExceptionInfo represents a JavaScript exception.
|
// ExceptionInfo represents a JavaScript exception.
|
||||||
// Field mapping from go-webview: LineNumber->Line, ColumnNumber->Column.
|
// Field mapping from go-webview: LineNumber->Line, ColumnNumber->Column.
|
||||||
|
// Use: err := webview.ExceptionInfo{Text: "ReferenceError", URL: "app://editor"}
|
||||||
type ExceptionInfo struct {
|
type ExceptionInfo struct {
|
||||||
Text string `json:"text"`
|
Text string `json:"text"`
|
||||||
URL string `json:"url,omitempty"`
|
URL string `json:"url,omitempty"`
|
||||||
|
|
@ -165,7 +281,52 @@ type ExceptionInfo struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ScreenshotResult wraps raw PNG bytes as base64 for JSON/MCP transport.
|
// ScreenshotResult wraps raw PNG bytes as base64 for JSON/MCP transport.
|
||||||
|
// Use: shot := webview.ScreenshotResult{Base64: "iVBORw0KGgo=", MimeType: "image/png"}
|
||||||
type ScreenshotResult struct {
|
type ScreenshotResult struct {
|
||||||
Base64 string `json:"base64"`
|
Base64 string `json:"base64"`
|
||||||
MimeType string `json:"mimeType"` // always "image/png"
|
MimeType string `json:"mimeType"` // always "image/png"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PerformanceMetrics summarises browser performance timings.
|
||||||
|
// Use: metrics := webview.PerformanceMetrics{NavigationStart: 1.2, LoadEventEnd: 42.5}
|
||||||
|
type PerformanceMetrics struct {
|
||||||
|
NavigationStart float64 `json:"navigationStart"`
|
||||||
|
DOMContentLoaded float64 `json:"domContentLoaded"`
|
||||||
|
LoadEventEnd float64 `json:"loadEventEnd"`
|
||||||
|
FirstPaint float64 `json:"firstPaint,omitempty"`
|
||||||
|
FirstContentfulPaint float64 `json:"firstContentfulPaint,omitempty"`
|
||||||
|
UsedJSHeapSize float64 `json:"usedJSHeapSize,omitempty"`
|
||||||
|
TotalJSHeapSize float64 `json:"totalJSHeapSize,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResourceEntry summarises a loaded resource.
|
||||||
|
// Use: entry := webview.ResourceEntry{Name: "app.js", EntryType: "resource"}
|
||||||
|
type ResourceEntry struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
EntryType string `json:"entryType"`
|
||||||
|
InitiatorType string `json:"initiatorType,omitempty"`
|
||||||
|
StartTime float64 `json:"startTime"`
|
||||||
|
Duration float64 `json:"duration"`
|
||||||
|
TransferSize float64 `json:"transferSize,omitempty"`
|
||||||
|
EncodedBodySize float64 `json:"encodedBodySize,omitempty"`
|
||||||
|
DecodedBodySize float64 `json:"decodedBodySize,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NetworkEntry summarises a captured fetch/XHR request.
|
||||||
|
// Use: entry := webview.NetworkEntry{URL: "/api/status", Method: "GET", Status: 200}
|
||||||
|
type NetworkEntry struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Method string `json:"method"`
|
||||||
|
Status int `json:"status,omitempty"`
|
||||||
|
Resource string `json:"resource,omitempty"`
|
||||||
|
OK bool `json:"ok,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Timestamp int64 `json:"timestamp,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PDFResult contains exported PDF bytes encoded for transport.
|
||||||
|
// Use: pdf := webview.PDFResult{Base64: "JVBERi0xLjQ=", MimeType: "application/pdf"}
|
||||||
|
type PDFResult struct {
|
||||||
|
Base64 string `json:"base64"`
|
||||||
|
MimeType string `json:"mimeType"` // always "application/pdf"
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,18 @@ import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/draw"
|
||||||
|
"image/png"
|
||||||
|
"math"
|
||||||
|
"reflect"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
gowebview "forge.lthn.ai/core/go-webview"
|
gowebview "forge.lthn.ai/core/go-webview"
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
"forge.lthn.ai/core/go/pkg/core"
|
||||||
|
|
@ -34,10 +43,13 @@ type connector interface {
|
||||||
ClearConsole()
|
ClearConsole()
|
||||||
SetViewport(width, height int) error
|
SetViewport(width, height int) error
|
||||||
UploadFile(selector string, paths []string) error
|
UploadFile(selector string, paths []string) error
|
||||||
|
Print() error
|
||||||
|
PrintToPDF() ([]byte, error)
|
||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Options holds configuration for the webview service.
|
// Options holds configuration for the webview service.
|
||||||
|
// Use: svc, err := webview.Register(webview.Options{})(core.New())
|
||||||
type Options struct {
|
type Options struct {
|
||||||
DebugURL string // Chrome debug endpoint (default: "http://localhost:9222")
|
DebugURL string // Chrome debug endpoint (default: "http://localhost:9222")
|
||||||
Timeout time.Duration // Operation timeout (default: 30s)
|
Timeout time.Duration // Operation timeout (default: 30s)
|
||||||
|
|
@ -45,75 +57,78 @@ type Options struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service is a core.Service managing webview interactions via IPC.
|
// Service is a core.Service managing webview interactions via IPC.
|
||||||
|
// Use: svc, err := webview.Register(webview.Options{})(core.New())
|
||||||
type Service struct {
|
type Service struct {
|
||||||
*core.ServiceRuntime[Options]
|
*core.ServiceRuntime[Options]
|
||||||
options Options
|
opts Options
|
||||||
connections map[string]connector
|
connections map[string]connector
|
||||||
|
exceptions map[string][]ExceptionInfo
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
newConn func(debugURL, windowName string) (connector, error) // injectable for tests
|
newConn func(debugURL, windowName string) (connector, error) // injectable for tests
|
||||||
watcherSetup func(conn connector, windowName string) // called after connection creation
|
watcherSetup func(conn connector, windowName string) // called after connection creation
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build a Core factory from one declarative Options value.
|
// Register creates a factory closure with declarative options.
|
||||||
// factory := webview.Register(webview.Options{DebugURL: "http://localhost:9333"})
|
// Use: core.WithService(webview.Register(webview.Options{ConsoleLimit: 500}))
|
||||||
func Register(options Options) func(*core.Core) (any, error) {
|
func Register(options Options) func(*core.Core) (any, error) {
|
||||||
options = defaultOptions(options)
|
o := Options{
|
||||||
|
DebugURL: "http://localhost:9222",
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
ConsoleLimit: 1000,
|
||||||
|
}
|
||||||
|
if options.DebugURL != "" {
|
||||||
|
o.DebugURL = options.DebugURL
|
||||||
|
}
|
||||||
|
if options.Timeout != 0 {
|
||||||
|
o.Timeout = options.Timeout
|
||||||
|
}
|
||||||
|
if options.ConsoleLimit != 0 {
|
||||||
|
o.ConsoleLimit = options.ConsoleLimit
|
||||||
|
}
|
||||||
return func(c *core.Core) (any, error) {
|
return func(c *core.Core) (any, error) {
|
||||||
svc := &Service{
|
svc := &Service{
|
||||||
ServiceRuntime: core.NewServiceRuntime[Options](c, options),
|
ServiceRuntime: core.NewServiceRuntime[Options](c, o),
|
||||||
options: options,
|
opts: o,
|
||||||
connections: make(map[string]connector),
|
connections: make(map[string]connector),
|
||||||
newConn: defaultNewConn(options),
|
exceptions: make(map[string][]ExceptionInfo),
|
||||||
|
newConn: defaultNewConn(o),
|
||||||
}
|
}
|
||||||
svc.watcherSetup = svc.defaultWatcherSetup
|
svc.watcherSetup = svc.defaultWatcherSetup
|
||||||
return svc, nil
|
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.
|
// defaultNewConn creates real go-webview connections.
|
||||||
func defaultNewConn(options Options) func(string, string) (connector, error) {
|
func defaultNewConn(opts Options) func(string, string) (connector, error) {
|
||||||
return func(debugURL, windowName string) (connector, error) {
|
return func(debugURL, windowName string) (connector, error) {
|
||||||
// Enumerate targets, match by title/URL containing window name
|
// Enumerate targets, match by title/URL containing window name
|
||||||
targets, err := gowebview.ListTargets(debugURL)
|
targets, err := gowebview.ListTargets(debugURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
var webSocketURL string
|
var wsURL string
|
||||||
for _, t := range targets {
|
for _, t := range targets {
|
||||||
if t.Type == "page" && (bytes.Contains([]byte(t.Title), []byte(windowName)) || bytes.Contains([]byte(t.URL), []byte(windowName))) {
|
if t.Type == "page" && (strings.Contains(t.Title, windowName) || strings.Contains(t.URL, windowName)) {
|
||||||
webSocketURL = t.WebSocketDebuggerURL
|
wsURL = t.WebSocketDebuggerURL
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fallback: first page target
|
// Fallback: first page target
|
||||||
if webSocketURL == "" {
|
if wsURL == "" {
|
||||||
for _, t := range targets {
|
for _, t := range targets {
|
||||||
if t.Type == "page" {
|
if t.Type == "page" {
|
||||||
webSocketURL = t.WebSocketDebuggerURL
|
wsURL = t.WebSocketDebuggerURL
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if webSocketURL == "" {
|
if wsURL == "" {
|
||||||
return nil, core.E("webview.connect", "no page target found", nil)
|
return nil, core.E("webview.connect", "no page target found", nil)
|
||||||
}
|
}
|
||||||
wv, err := gowebview.New(
|
wv, err := gowebview.New(
|
||||||
gowebview.WithDebugURL(debugURL),
|
gowebview.WithDebugURL(debugURL),
|
||||||
gowebview.WithTimeout(options.Timeout),
|
gowebview.WithTimeout(opts.Timeout),
|
||||||
gowebview.WithConsoleLimit(options.ConsoleLimit),
|
gowebview.WithConsoleLimit(opts.ConsoleLimit),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -130,8 +145,8 @@ func (s *Service) defaultWatcherSetup(conn connector, windowName string) {
|
||||||
return // test mocks don't need watchers
|
return // test mocks don't need watchers
|
||||||
}
|
}
|
||||||
|
|
||||||
consoleWatcher := gowebview.NewConsoleWatcher(rc.wv)
|
cw := gowebview.NewConsoleWatcher(rc.wv)
|
||||||
consoleWatcher.AddHandler(func(msg gowebview.ConsoleMessage) {
|
cw.AddHandler(func(msg gowebview.ConsoleMessage) {
|
||||||
_ = s.Core().ACTION(ActionConsoleMessage{
|
_ = s.Core().ACTION(ActionConsoleMessage{
|
||||||
Window: windowName,
|
Window: windowName,
|
||||||
Message: ConsoleMessage{
|
Message: ConsoleMessage{
|
||||||
|
|
@ -145,8 +160,8 @@ func (s *Service) defaultWatcherSetup(conn connector, windowName string) {
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
exceptionWatcher := gowebview.NewExceptionWatcher(rc.wv)
|
ew := gowebview.NewExceptionWatcher(rc.wv)
|
||||||
exceptionWatcher.AddHandler(func(exc gowebview.ExceptionInfo) {
|
ew.AddHandler(func(exc gowebview.ExceptionInfo) {
|
||||||
_ = s.Core().ACTION(ActionException{
|
_ = s.Core().ACTION(ActionException{
|
||||||
Window: windowName,
|
Window: windowName,
|
||||||
Exception: ExceptionInfo{
|
Exception: ExceptionInfo{
|
||||||
|
|
@ -188,7 +203,10 @@ func (s *Service) HandleIPCEvents(_ *core.Core, msg core.Message) error {
|
||||||
conn.Close()
|
conn.Close()
|
||||||
delete(s.connections, m.Name)
|
delete(s.connections, m.Name)
|
||||||
}
|
}
|
||||||
|
delete(s.exceptions, m.Name)
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
case ActionException:
|
||||||
|
s.recordException(m.Window, m.Exception)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -208,7 +226,7 @@ func (s *Service) getConn(windowName string) (connector, error) {
|
||||||
if conn, ok := s.connections[windowName]; ok {
|
if conn, ok := s.connections[windowName]; ok {
|
||||||
return conn, nil
|
return conn, nil
|
||||||
}
|
}
|
||||||
conn, err := s.newConn(s.options.DebugURL, windowName)
|
conn, err := s.newConn(s.opts.DebugURL, windowName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -281,6 +299,64 @@ func (s *Service) handleQuery(_ *core.Core, q core.Query) (any, bool, error) {
|
||||||
}
|
}
|
||||||
html, err := conn.GetHTML(selector)
|
html, err := conn.GetHTML(selector)
|
||||||
return html, true, err
|
return html, true, err
|
||||||
|
case QueryComputedStyle:
|
||||||
|
conn, err := s.getConn(q.Window)
|
||||||
|
if err != nil {
|
||||||
|
return nil, true, err
|
||||||
|
}
|
||||||
|
result, err := conn.Evaluate(computedStyleScript(q.Selector))
|
||||||
|
if err != nil {
|
||||||
|
return nil, true, err
|
||||||
|
}
|
||||||
|
style, err := coerceToMapStringString(result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, true, err
|
||||||
|
}
|
||||||
|
return style, true, nil
|
||||||
|
case QueryPerformance:
|
||||||
|
conn, err := s.getConn(q.Window)
|
||||||
|
if err != nil {
|
||||||
|
return nil, true, err
|
||||||
|
}
|
||||||
|
result, err := conn.Evaluate(performanceScript())
|
||||||
|
if err != nil {
|
||||||
|
return nil, true, err
|
||||||
|
}
|
||||||
|
metrics, err := coerceToPerformanceMetrics(result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, true, err
|
||||||
|
}
|
||||||
|
return metrics, true, nil
|
||||||
|
case QueryResources:
|
||||||
|
conn, err := s.getConn(q.Window)
|
||||||
|
if err != nil {
|
||||||
|
return nil, true, err
|
||||||
|
}
|
||||||
|
result, err := conn.Evaluate(resourcesScript())
|
||||||
|
if err != nil {
|
||||||
|
return nil, true, err
|
||||||
|
}
|
||||||
|
resources, err := coerceToResourceEntries(result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, true, err
|
||||||
|
}
|
||||||
|
return resources, true, nil
|
||||||
|
case QueryNetwork:
|
||||||
|
conn, err := s.getConn(q.Window)
|
||||||
|
if err != nil {
|
||||||
|
return nil, true, err
|
||||||
|
}
|
||||||
|
result, err := conn.Evaluate(networkLogScript(q.Limit))
|
||||||
|
if err != nil {
|
||||||
|
return nil, true, err
|
||||||
|
}
|
||||||
|
entries, err := coerceToNetworkEntries(result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, true, err
|
||||||
|
}
|
||||||
|
return entries, true, nil
|
||||||
|
case QueryExceptions:
|
||||||
|
return s.queryExceptions(q.Window, q.Limit), true, nil
|
||||||
default:
|
default:
|
||||||
return nil, false, nil
|
return nil, false, nil
|
||||||
}
|
}
|
||||||
|
|
@ -326,6 +402,19 @@ func (s *Service) handleTask(_ *core.Core, t core.Task) (any, bool, error) {
|
||||||
Base64: base64.StdEncoding.EncodeToString(png),
|
Base64: base64.StdEncoding.EncodeToString(png),
|
||||||
MimeType: "image/png",
|
MimeType: "image/png",
|
||||||
}, true, nil
|
}, true, nil
|
||||||
|
case TaskScreenshotElement:
|
||||||
|
conn, err := s.getConn(t.Window)
|
||||||
|
if err != nil {
|
||||||
|
return nil, true, err
|
||||||
|
}
|
||||||
|
png, err := captureElementScreenshot(conn, t.Selector)
|
||||||
|
if err != nil {
|
||||||
|
return nil, true, err
|
||||||
|
}
|
||||||
|
return ScreenshotResult{
|
||||||
|
Base64: base64.StdEncoding.EncodeToString(png),
|
||||||
|
MimeType: "image/png",
|
||||||
|
}, true, nil
|
||||||
case TaskScroll:
|
case TaskScroll:
|
||||||
conn, err := s.getConn(t.Window)
|
conn, err := s.getConn(t.Window)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -370,11 +459,208 @@ func (s *Service) handleTask(_ *core.Core, t core.Task) (any, bool, error) {
|
||||||
}
|
}
|
||||||
conn.ClearConsole()
|
conn.ClearConsole()
|
||||||
return nil, true, nil
|
return nil, true, nil
|
||||||
|
case TaskHighlight:
|
||||||
|
conn, err := s.getConn(t.Window)
|
||||||
|
if err != nil {
|
||||||
|
return nil, true, err
|
||||||
|
}
|
||||||
|
_, err = conn.Evaluate(highlightScript(t.Selector, t.Colour))
|
||||||
|
return nil, true, err
|
||||||
|
case TaskOpenDevTools:
|
||||||
|
ws, err := core.ServiceFor[*window.Service](s.Core(), "window")
|
||||||
|
if err != nil {
|
||||||
|
return nil, true, err
|
||||||
|
}
|
||||||
|
pw, ok := ws.Manager().Get(t.Window)
|
||||||
|
if !ok {
|
||||||
|
return nil, true, fmt.Errorf("window not found: %s", t.Window)
|
||||||
|
}
|
||||||
|
pw.OpenDevTools()
|
||||||
|
return nil, true, nil
|
||||||
|
case TaskCloseDevTools:
|
||||||
|
ws, err := core.ServiceFor[*window.Service](s.Core(), "window")
|
||||||
|
if err != nil {
|
||||||
|
return nil, true, err
|
||||||
|
}
|
||||||
|
pw, ok := ws.Manager().Get(t.Window)
|
||||||
|
if !ok {
|
||||||
|
return nil, true, fmt.Errorf("window not found: %s", t.Window)
|
||||||
|
}
|
||||||
|
pw.CloseDevTools()
|
||||||
|
return nil, true, nil
|
||||||
|
case TaskInjectNetworkLogging:
|
||||||
|
conn, err := s.getConn(t.Window)
|
||||||
|
if err != nil {
|
||||||
|
return nil, true, err
|
||||||
|
}
|
||||||
|
_, err = conn.Evaluate(networkInitScript())
|
||||||
|
return nil, true, err
|
||||||
|
case TaskClearNetworkLog:
|
||||||
|
conn, err := s.getConn(t.Window)
|
||||||
|
if err != nil {
|
||||||
|
return nil, true, err
|
||||||
|
}
|
||||||
|
_, err = conn.Evaluate(networkClearScript())
|
||||||
|
return nil, true, err
|
||||||
|
case TaskPrint:
|
||||||
|
conn, err := s.getConn(t.Window)
|
||||||
|
if err != nil {
|
||||||
|
return nil, true, err
|
||||||
|
}
|
||||||
|
return nil, true, conn.Print()
|
||||||
|
case TaskExportPDF:
|
||||||
|
conn, err := s.getConn(t.Window)
|
||||||
|
if err != nil {
|
||||||
|
return nil, true, err
|
||||||
|
}
|
||||||
|
pdf, err := conn.PrintToPDF()
|
||||||
|
if err != nil {
|
||||||
|
return nil, true, err
|
||||||
|
}
|
||||||
|
return PDFResult{
|
||||||
|
Base64: base64.StdEncoding.EncodeToString(pdf),
|
||||||
|
MimeType: "application/pdf",
|
||||||
|
}, true, nil
|
||||||
default:
|
default:
|
||||||
return nil, false, nil
|
return nil, false, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) recordException(windowName string, exc ExceptionInfo) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
|
||||||
|
exceptions := append(s.exceptions[windowName], exc)
|
||||||
|
if limit := s.opts.ConsoleLimit; limit > 0 && len(exceptions) > limit {
|
||||||
|
exceptions = exceptions[len(exceptions)-limit:]
|
||||||
|
}
|
||||||
|
s.exceptions[windowName] = exceptions
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) queryExceptions(windowName string, limit int) []ExceptionInfo {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
exceptions := append([]ExceptionInfo(nil), s.exceptions[windowName]...)
|
||||||
|
if limit > 0 && len(exceptions) > limit {
|
||||||
|
exceptions = exceptions[len(exceptions)-limit:]
|
||||||
|
}
|
||||||
|
return exceptions
|
||||||
|
}
|
||||||
|
|
||||||
|
func coerceJSON[T any](v any) (T, error) {
|
||||||
|
var out T
|
||||||
|
raw, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(raw, &out); err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func coerceToMapStringString(v any) (map[string]string, error) {
|
||||||
|
return coerceJSON[map[string]string](v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func coerceToPerformanceMetrics(v any) (PerformanceMetrics, error) {
|
||||||
|
return coerceJSON[PerformanceMetrics](v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func coerceToResourceEntries(v any) ([]ResourceEntry, error) {
|
||||||
|
return coerceJSON[[]ResourceEntry](v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func coerceToNetworkEntries(v any) ([]NetworkEntry, error) {
|
||||||
|
return coerceJSON[[]NetworkEntry](v)
|
||||||
|
}
|
||||||
|
|
||||||
|
type elementScreenshotBounds struct {
|
||||||
|
Left float64 `json:"left"`
|
||||||
|
Top float64 `json:"top"`
|
||||||
|
Width float64 `json:"width"`
|
||||||
|
Height float64 `json:"height"`
|
||||||
|
DevicePixelRatio float64 `json:"devicePixelRatio"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func elementScreenshotScript(selector string) string {
|
||||||
|
sel := jsQuote(selector)
|
||||||
|
return fmt.Sprintf(`(function(){
|
||||||
|
const el = document.querySelector(%s);
|
||||||
|
if (!el) return null;
|
||||||
|
try { el.scrollIntoView({block: "center", inline: "center"}); } catch (e) {}
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
left: rect.left,
|
||||||
|
top: rect.top,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
devicePixelRatio: window.devicePixelRatio || 1
|
||||||
|
};
|
||||||
|
})()`, sel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func captureElementScreenshot(conn connector, selector string) ([]byte, error) {
|
||||||
|
result, err := conn.Evaluate(elementScreenshotScript(selector))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if result == nil {
|
||||||
|
return nil, fmt.Errorf("webview: element not found: %s", selector)
|
||||||
|
}
|
||||||
|
bounds, err := coerceJSON[elementScreenshotBounds](result)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if bounds.Width <= 0 || bounds.Height <= 0 {
|
||||||
|
return nil, fmt.Errorf("webview: element has no measurable bounds: %s", selector)
|
||||||
|
}
|
||||||
|
raw, err := conn.Screenshot()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
img, _, err := image.Decode(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
scale := bounds.DevicePixelRatio
|
||||||
|
if scale <= 0 {
|
||||||
|
scale = 1
|
||||||
|
}
|
||||||
|
left := int(math.Floor(bounds.Left * scale))
|
||||||
|
top := int(math.Floor(bounds.Top * scale))
|
||||||
|
right := int(math.Ceil((bounds.Left + bounds.Width) * scale))
|
||||||
|
bottom := int(math.Ceil((bounds.Top + bounds.Height) * scale))
|
||||||
|
|
||||||
|
srcBounds := img.Bounds()
|
||||||
|
if left < srcBounds.Min.X {
|
||||||
|
left = srcBounds.Min.X
|
||||||
|
}
|
||||||
|
if top < srcBounds.Min.Y {
|
||||||
|
top = srcBounds.Min.Y
|
||||||
|
}
|
||||||
|
if right > srcBounds.Max.X {
|
||||||
|
right = srcBounds.Max.X
|
||||||
|
}
|
||||||
|
if bottom > srcBounds.Max.Y {
|
||||||
|
bottom = srcBounds.Max.Y
|
||||||
|
}
|
||||||
|
if right <= left || bottom <= top {
|
||||||
|
return nil, fmt.Errorf("webview: element is outside the captured screenshot: %s", selector)
|
||||||
|
}
|
||||||
|
|
||||||
|
crop := image.NewRGBA(image.Rect(0, 0, right-left, bottom-top))
|
||||||
|
draw.Draw(crop, crop.Bounds(), img, image.Point{X: left, Y: top}, draw.Src)
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := png.Encode(&buf, crop); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
// realConnector wraps *gowebview.Webview, converting types at the boundary.
|
// realConnector wraps *gowebview.Webview, converting types at the boundary.
|
||||||
type realConnector struct {
|
type realConnector struct {
|
||||||
wv *gowebview.Webview
|
wv *gowebview.Webview
|
||||||
|
|
@ -389,9 +675,48 @@ func (r *realConnector) GetURL() (string, error) { return r.wv.G
|
||||||
func (r *realConnector) GetTitle() (string, error) { return r.wv.GetTitle() }
|
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) GetHTML(sel string) (string, error) { return r.wv.GetHTML(sel) }
|
||||||
func (r *realConnector) ClearConsole() { r.wv.ClearConsole() }
|
func (r *realConnector) ClearConsole() { r.wv.ClearConsole() }
|
||||||
|
func (r *realConnector) Print() error { _, err := r.wv.Evaluate("window.print()"); return err }
|
||||||
func (r *realConnector) Close() error { return r.wv.Close() }
|
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) 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) UploadFile(sel string, p []string) error { return r.wv.UploadFile(sel, p) }
|
||||||
|
func (r *realConnector) PrintToPDF() ([]byte, error) {
|
||||||
|
client, err := r.cdpClient()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
result, err := client.Call(ctx, "Page.printToPDF", map[string]any{
|
||||||
|
"printBackground": true,
|
||||||
|
"preferCSSPageSize": true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data, ok := result["data"].(string)
|
||||||
|
if !ok || data == "" {
|
||||||
|
return nil, fmt.Errorf("webview: missing PDF data")
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.DecodeString(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *realConnector) cdpClient() (*gowebview.CDPClient, error) {
|
||||||
|
rv := reflect.ValueOf(r.wv)
|
||||||
|
if rv.Kind() != reflect.Ptr || rv.IsNil() {
|
||||||
|
return nil, fmt.Errorf("webview: invalid connector")
|
||||||
|
}
|
||||||
|
elem := rv.Elem()
|
||||||
|
field := elem.FieldByName("client")
|
||||||
|
if !field.IsValid() || field.IsNil() {
|
||||||
|
return nil, fmt.Errorf("webview: CDP client not available")
|
||||||
|
}
|
||||||
|
ptr := reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Interface()
|
||||||
|
client, ok := ptr.(*gowebview.CDPClient)
|
||||||
|
if !ok || client == nil {
|
||||||
|
return nil, fmt.Errorf("webview: unexpected CDP client type")
|
||||||
|
}
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *realConnector) Hover(sel string) error {
|
func (r *realConnector) Hover(sel string) error {
|
||||||
return gowebview.NewActionSequence().Add(&gowebview.HoverAction{Selector: sel}).Execute(context.Background(), r.wv)
|
return gowebview.NewActionSequence().Add(&gowebview.HoverAction{Selector: sel}).Execute(context.Background(), r.wv)
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,14 @@
|
||||||
package webview
|
package webview
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"image"
|
||||||
|
"image/color"
|
||||||
|
"image/png"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
"forge.lthn.ai/core/go/pkg/core"
|
||||||
"forge.lthn.ai/core/gui/pkg/window"
|
"forge.lthn.ai/core/gui/pkg/window"
|
||||||
|
|
@ -17,10 +22,13 @@ type mockConnector struct {
|
||||||
title string
|
title string
|
||||||
html string
|
html string
|
||||||
evalResult any
|
evalResult any
|
||||||
|
evalFn func(script string) (any, error)
|
||||||
screenshot []byte
|
screenshot []byte
|
||||||
console []ConsoleMessage
|
console []ConsoleMessage
|
||||||
elements []*ElementInfo
|
elements []*ElementInfo
|
||||||
closed bool
|
closed bool
|
||||||
|
pdfBytes []byte
|
||||||
|
printCalled bool
|
||||||
|
|
||||||
lastClickSel string
|
lastClickSel string
|
||||||
lastTypeSel string
|
lastTypeSel string
|
||||||
|
|
@ -36,6 +44,7 @@ type mockConnector struct {
|
||||||
lastViewportW int
|
lastViewportW int
|
||||||
lastViewportH int
|
lastViewportH int
|
||||||
consoleClearCalled bool
|
consoleClearCalled bool
|
||||||
|
lastEvalScript string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockConnector) Navigate(url string) error { m.lastNavURL = url; return nil }
|
func (m *mockConnector) Navigate(url string) error { m.lastNavURL = url; return nil }
|
||||||
|
|
@ -56,18 +65,31 @@ func (m *mockConnector) Check(sel string, c bool) error {
|
||||||
m.lastCheckVal = c
|
m.lastCheckVal = c
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func (m *mockConnector) Evaluate(s string) (any, error) { return m.evalResult, nil }
|
func (m *mockConnector) Evaluate(s string) (any, error) {
|
||||||
|
m.lastEvalScript = s
|
||||||
|
if m.evalFn != nil {
|
||||||
|
return m.evalFn(s)
|
||||||
|
}
|
||||||
|
return m.evalResult, nil
|
||||||
|
}
|
||||||
func (m *mockConnector) Screenshot() ([]byte, error) { return m.screenshot, nil }
|
func (m *mockConnector) Screenshot() ([]byte, error) { return m.screenshot, nil }
|
||||||
func (m *mockConnector) GetURL() (string, error) { return m.url, nil }
|
func (m *mockConnector) GetURL() (string, error) { return m.url, nil }
|
||||||
func (m *mockConnector) GetTitle() (string, error) { return m.title, 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) GetHTML(sel string) (string, error) { return m.html, nil }
|
||||||
func (m *mockConnector) ClearConsole() { m.consoleClearCalled = true }
|
func (m *mockConnector) ClearConsole() { m.consoleClearCalled = true }
|
||||||
|
func (m *mockConnector) Print() error { m.printCalled = true; return nil }
|
||||||
func (m *mockConnector) Close() error { m.closed = true; return nil }
|
func (m *mockConnector) Close() error { m.closed = true; return nil }
|
||||||
func (m *mockConnector) SetViewport(w, h int) error {
|
func (m *mockConnector) SetViewport(w, h int) error {
|
||||||
m.lastViewportW = w
|
m.lastViewportW = w
|
||||||
m.lastViewportH = h
|
m.lastViewportH = h
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
func (m *mockConnector) PrintToPDF() ([]byte, error) {
|
||||||
|
if len(m.pdfBytes) == 0 {
|
||||||
|
return []byte("%PDF-1.4\n"), nil
|
||||||
|
}
|
||||||
|
return m.pdfBytes, nil
|
||||||
|
}
|
||||||
func (m *mockConnector) UploadFile(sel string, p []string) error {
|
func (m *mockConnector) UploadFile(sel string, p []string) error {
|
||||||
m.lastUploadSel = sel
|
m.lastUploadSel = sel
|
||||||
m.lastUploadPaths = p
|
m.lastUploadPaths = p
|
||||||
|
|
@ -90,7 +112,11 @@ func (m *mockConnector) GetConsole() []ConsoleMessage { return m.console }
|
||||||
func newTestService(t *testing.T, mock *mockConnector) (*Service, *core.Core) {
|
func newTestService(t *testing.T, mock *mockConnector) (*Service, *core.Core) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
factory := Register(Options{})
|
factory := Register(Options{})
|
||||||
c, err := core.New(core.WithService(factory), core.WithServiceLock())
|
c, err := core.New(
|
||||||
|
core.WithService(window.Register(window.NewMockPlatform())),
|
||||||
|
core.WithService(factory),
|
||||||
|
core.WithServiceLock(),
|
||||||
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
||||||
svc := core.MustServiceFor[*Service](c, "webview")
|
svc := core.MustServiceFor[*Service](c, "webview")
|
||||||
|
|
@ -104,22 +130,6 @@ func TestRegister_Good(t *testing.T) {
|
||||||
assert.NotNil(t, svc)
|
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) {
|
func TestQueryURL_Good(t *testing.T) {
|
||||||
_, c := newTestService(t, &mockConnector{url: "https://example.com"})
|
_, c := newTestService(t, &mockConnector{url: "https://example.com"})
|
||||||
result, handled, err := c.QUERY(QueryURL{Window: "main"})
|
result, handled, err := c.QUERY(QueryURL{Window: "main"})
|
||||||
|
|
@ -164,6 +174,29 @@ func TestQueryConsole_Good_Limit(t *testing.T) {
|
||||||
assert.Equal(t, "b", msgs[0].Text) // last 2
|
assert.Equal(t, "b", msgs[0].Text) // last 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestQueryExceptions_Good(t *testing.T) {
|
||||||
|
_, c := newTestService(t, &mockConnector{})
|
||||||
|
|
||||||
|
require.NoError(t, c.ACTION(ActionException{
|
||||||
|
Window: "main",
|
||||||
|
Exception: ExceptionInfo{
|
||||||
|
Text: "boom",
|
||||||
|
URL: "https://example.com/app.js",
|
||||||
|
Line: 12,
|
||||||
|
Column: 4,
|
||||||
|
StackTrace: "Error: boom",
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
result, handled, err := c.QUERY(QueryExceptions{Window: "main"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
exceptions, _ := result.([]ExceptionInfo)
|
||||||
|
require.Len(t, exceptions, 1)
|
||||||
|
assert.Equal(t, "boom", exceptions[0].Text)
|
||||||
|
assert.Equal(t, 12, exceptions[0].Line)
|
||||||
|
}
|
||||||
|
|
||||||
func TestTaskEvaluate_Good(t *testing.T) {
|
func TestTaskEvaluate_Good(t *testing.T) {
|
||||||
_, c := newTestService(t, &mockConnector{evalResult: 42})
|
_, c := newTestService(t, &mockConnector{evalResult: 42})
|
||||||
result, handled, err := c.PERFORM(TaskEvaluate{Window: "main", Script: "21*2"})
|
result, handled, err := c.PERFORM(TaskEvaluate{Window: "main", Script: "21*2"})
|
||||||
|
|
@ -202,6 +235,43 @@ func TestTaskScreenshot_Good(t *testing.T) {
|
||||||
assert.NotEmpty(t, sr.Base64)
|
assert.NotEmpty(t, sr.Base64)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTaskScreenshotElement_Good(t *testing.T) {
|
||||||
|
img := image.NewRGBA(image.Rect(0, 0, 4, 4))
|
||||||
|
for y := 0; y < 4; y++ {
|
||||||
|
for x := 0; x < 4; x++ {
|
||||||
|
img.SetRGBA(x, y, color.RGBA{R: uint8(x * 40), G: uint8(y * 40), B: 200, A: 255})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
require.NoError(t, png.Encode(&buf, img))
|
||||||
|
|
||||||
|
mock := &mockConnector{
|
||||||
|
screenshot: buf.Bytes(),
|
||||||
|
evalFn: func(script string) (any, error) {
|
||||||
|
return map[string]any{
|
||||||
|
"left": 1.0,
|
||||||
|
"top": 1.0,
|
||||||
|
"width": 2.0,
|
||||||
|
"height": 2.0,
|
||||||
|
"devicePixelRatio": 1.0,
|
||||||
|
}, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, c := newTestService(t, mock)
|
||||||
|
|
||||||
|
result, handled, err := c.PERFORM(TaskScreenshotElement{Window: "main", Selector: "#card"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
sr, ok := result.(ScreenshotResult)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(sr.Base64)
|
||||||
|
require.NoError(t, err)
|
||||||
|
decoded, err := png.Decode(bytes.NewReader(raw))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, image.Rect(0, 0, 2, 2), decoded.Bounds())
|
||||||
|
}
|
||||||
|
|
||||||
func TestTaskClearConsole_Good(t *testing.T) {
|
func TestTaskClearConsole_Good(t *testing.T) {
|
||||||
mock := &mockConnector{}
|
mock := &mockConnector{}
|
||||||
_, c := newTestService(t, mock)
|
_, c := newTestService(t, mock)
|
||||||
|
|
@ -211,6 +281,102 @@ func TestTaskClearConsole_Good(t *testing.T) {
|
||||||
assert.True(t, mock.consoleClearCalled)
|
assert.True(t, mock.consoleClearCalled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTaskDevTools_Good(t *testing.T) {
|
||||||
|
_, c := newTestService(t, &mockConnector{})
|
||||||
|
_, _, err := c.PERFORM(window.TaskOpenWindow{Opts: []window.WindowOption{window.WithName("main")}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, handled, err := c.PERFORM(TaskOpenDevTools{Window: "main"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
_, handled, err = c.PERFORM(TaskCloseDevTools{Window: "main"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiagnosticsQueries_Good(t *testing.T) {
|
||||||
|
mock := &mockConnector{
|
||||||
|
evalFn: func(script string) (any, error) {
|
||||||
|
switch {
|
||||||
|
case strings.Contains(script, "getComputedStyle"):
|
||||||
|
return map[string]any{"color": "rgb(1, 2, 3)"}, nil
|
||||||
|
case strings.Contains(script, "performance.getEntriesByType(\"navigation\")"):
|
||||||
|
return map[string]any{
|
||||||
|
"navigationStart": 1.0,
|
||||||
|
"domContentLoaded": 2.0,
|
||||||
|
"loadEventEnd": 3.0,
|
||||||
|
"firstPaint": 4.0,
|
||||||
|
"firstContentfulPaint": 5.0,
|
||||||
|
"usedJSHeapSize": 6.0,
|
||||||
|
"totalJSHeapSize": 7.0,
|
||||||
|
}, nil
|
||||||
|
case strings.Contains(script, "performance.getEntriesByType(\"resource\")"):
|
||||||
|
return []any{
|
||||||
|
map[string]any{"name": "app.js", "entryType": "resource", "initiatorType": "script"},
|
||||||
|
}, nil
|
||||||
|
case strings.Contains(script, "window.__coreNetworkLog"):
|
||||||
|
return []any{
|
||||||
|
map[string]any{"url": "https://example.com", "method": "GET", "status": 200, "resource": "fetch"},
|
||||||
|
}, nil
|
||||||
|
default:
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, c := newTestService(t, mock)
|
||||||
|
|
||||||
|
style, handled, err := c.QUERY(QueryComputedStyle{Window: "main", Selector: "#app"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
assert.Equal(t, "rgb(1, 2, 3)", style.(map[string]string)["color"])
|
||||||
|
|
||||||
|
perf, handled, err := c.QUERY(QueryPerformance{Window: "main"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
assert.Equal(t, 1.0, perf.(PerformanceMetrics).NavigationStart)
|
||||||
|
|
||||||
|
resources, handled, err := c.QUERY(QueryResources{Window: "main"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
assert.Len(t, resources.([]ResourceEntry), 1)
|
||||||
|
|
||||||
|
network, handled, err := c.QUERY(QueryNetwork{Window: "main", Limit: 10})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
assert.Len(t, network.([]NetworkEntry), 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDiagnosticsTasks_Good(t *testing.T) {
|
||||||
|
mock := &mockConnector{pdfBytes: []byte("%PDF-1.7")}
|
||||||
|
_, c := newTestService(t, mock)
|
||||||
|
|
||||||
|
_, handled, err := c.PERFORM(TaskHighlight{Window: "main", Selector: "#app", Colour: "#00ff00"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
assert.Contains(t, mock.lastEvalScript, "outline")
|
||||||
|
|
||||||
|
_, handled, err = c.PERFORM(TaskInjectNetworkLogging{Window: "main"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
assert.Contains(t, mock.lastEvalScript, "__coreNetworkLog")
|
||||||
|
|
||||||
|
_, handled, err = c.PERFORM(TaskClearNetworkLog{Window: "main"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
|
||||||
|
_, handled, err = c.PERFORM(TaskPrint{Window: "main"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
assert.True(t, mock.printCalled)
|
||||||
|
|
||||||
|
result, handled, err := c.PERFORM(TaskExportPDF{Window: "main"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
pdf, ok := result.(PDFResult)
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, "application/pdf", pdf.MimeType)
|
||||||
|
assert.NotEmpty(t, pdf.Base64)
|
||||||
|
}
|
||||||
|
|
||||||
func TestConnectionCleanup_Good(t *testing.T) {
|
func TestConnectionCleanup_Good(t *testing.T) {
|
||||||
mock := &mockConnector{}
|
mock := &mockConnector{}
|
||||||
_, c := newTestService(t, mock)
|
_, c := newTestService(t, mock)
|
||||||
|
|
|
||||||
|
|
@ -3,17 +3,15 @@ package window
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
coreio "forge.lthn.ai/core/go-io"
|
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Layout is a named window arrangement.
|
// Layout is a named window arrangement.
|
||||||
|
// Use: layout := window.Layout{Name: "coding"}
|
||||||
type Layout struct {
|
type Layout struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Windows map[string]WindowState `json:"windows"`
|
Windows map[string]WindowState `json:"windows"`
|
||||||
|
|
@ -22,6 +20,7 @@ type Layout struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// LayoutInfo is a summary of a layout.
|
// LayoutInfo is a summary of a layout.
|
||||||
|
// Use: info := window.LayoutInfo{Name: "coding", WindowCount: 2}
|
||||||
type LayoutInfo struct {
|
type LayoutInfo struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
WindowCount int `json:"windowCount"`
|
WindowCount int `json:"windowCount"`
|
||||||
|
|
@ -30,6 +29,7 @@ type LayoutInfo struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// LayoutManager persists named window arrangements to ~/.config/Core/layouts.json.
|
// LayoutManager persists named window arrangements to ~/.config/Core/layouts.json.
|
||||||
|
// Use: lm := window.NewLayoutManager()
|
||||||
type LayoutManager struct {
|
type LayoutManager struct {
|
||||||
configDir string
|
configDir string
|
||||||
layouts map[string]Layout
|
layouts map[string]Layout
|
||||||
|
|
@ -37,6 +37,7 @@ type LayoutManager struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLayoutManager creates a LayoutManager loading from the default config directory.
|
// NewLayoutManager creates a LayoutManager loading from the default config directory.
|
||||||
|
// Use: lm := window.NewLayoutManager()
|
||||||
func NewLayoutManager() *LayoutManager {
|
func NewLayoutManager() *LayoutManager {
|
||||||
lm := &LayoutManager{
|
lm := &LayoutManager{
|
||||||
layouts: make(map[string]Layout),
|
layouts: make(map[string]Layout),
|
||||||
|
|
@ -45,39 +46,40 @@ func NewLayoutManager() *LayoutManager {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
lm.configDir = filepath.Join(configDir, "Core")
|
lm.configDir = filepath.Join(configDir, "Core")
|
||||||
}
|
}
|
||||||
lm.load()
|
lm.loadLayouts()
|
||||||
return lm
|
return lm
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLayoutManagerWithDir creates a LayoutManager loading from a custom config directory.
|
// NewLayoutManagerWithDir creates a LayoutManager loading from a custom config directory.
|
||||||
// Useful for testing or when the default config directory is not appropriate.
|
// Useful for testing or when the default config directory is not appropriate.
|
||||||
|
// Use: lm := window.NewLayoutManagerWithDir(t.TempDir())
|
||||||
func NewLayoutManagerWithDir(configDir string) *LayoutManager {
|
func NewLayoutManagerWithDir(configDir string) *LayoutManager {
|
||||||
lm := &LayoutManager{
|
lm := &LayoutManager{
|
||||||
configDir: configDir,
|
configDir: configDir,
|
||||||
layouts: make(map[string]Layout),
|
layouts: make(map[string]Layout),
|
||||||
}
|
}
|
||||||
lm.load()
|
lm.loadLayouts()
|
||||||
return lm
|
return lm
|
||||||
}
|
}
|
||||||
|
|
||||||
func (lm *LayoutManager) filePath() string {
|
func (lm *LayoutManager) layoutsFilePath() string {
|
||||||
return filepath.Join(lm.configDir, "layouts.json")
|
return filepath.Join(lm.configDir, "layouts.json")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (lm *LayoutManager) load() {
|
func (lm *LayoutManager) loadLayouts() {
|
||||||
if lm.configDir == "" {
|
if lm.configDir == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
content, err := coreio.Local.Read(lm.filePath())
|
data, err := os.ReadFile(lm.layoutsFilePath())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lm.mu.Lock()
|
lm.mu.Lock()
|
||||||
defer lm.mu.Unlock()
|
defer lm.mu.Unlock()
|
||||||
_ = json.Unmarshal([]byte(content), &lm.layouts)
|
_ = json.Unmarshal(data, &lm.layouts)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (lm *LayoutManager) save() {
|
func (lm *LayoutManager) saveLayouts() {
|
||||||
if lm.configDir == "" {
|
if lm.configDir == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -87,14 +89,15 @@ func (lm *LayoutManager) save() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = coreio.Local.EnsureDir(lm.configDir)
|
_ = os.MkdirAll(lm.configDir, 0o755)
|
||||||
_ = coreio.Local.Write(lm.filePath(), string(data))
|
_ = os.WriteFile(lm.layoutsFilePath(), data, 0o644)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveLayout creates or updates a named layout.
|
// SaveLayout creates or updates a named layout.
|
||||||
|
// Use: _ = lm.SaveLayout("coding", windowStates)
|
||||||
func (lm *LayoutManager) SaveLayout(name string, windowStates map[string]WindowState) error {
|
func (lm *LayoutManager) SaveLayout(name string, windowStates map[string]WindowState) error {
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return coreerr.E("window.LayoutManager.SaveLayout", "layout name cannot be empty", nil)
|
return fmt.Errorf("layout name cannot be empty")
|
||||||
}
|
}
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now().UnixMilli()
|
||||||
lm.mu.Lock()
|
lm.mu.Lock()
|
||||||
|
|
@ -111,11 +114,12 @@ func (lm *LayoutManager) SaveLayout(name string, windowStates map[string]WindowS
|
||||||
}
|
}
|
||||||
lm.layouts[name] = layout
|
lm.layouts[name] = layout
|
||||||
lm.mu.Unlock()
|
lm.mu.Unlock()
|
||||||
lm.save()
|
lm.saveLayouts()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLayout returns a layout by name.
|
// GetLayout returns a layout by name.
|
||||||
|
// Use: layout, ok := lm.GetLayout("coding")
|
||||||
func (lm *LayoutManager) GetLayout(name string) (Layout, bool) {
|
func (lm *LayoutManager) GetLayout(name string) (Layout, bool) {
|
||||||
lm.mu.RLock()
|
lm.mu.RLock()
|
||||||
defer lm.mu.RUnlock()
|
defer lm.mu.RUnlock()
|
||||||
|
|
@ -124,6 +128,7 @@ func (lm *LayoutManager) GetLayout(name string) (Layout, bool) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListLayouts returns info summaries for all layouts.
|
// ListLayouts returns info summaries for all layouts.
|
||||||
|
// Use: layouts := lm.ListLayouts()
|
||||||
func (lm *LayoutManager) ListLayouts() []LayoutInfo {
|
func (lm *LayoutManager) ListLayouts() []LayoutInfo {
|
||||||
lm.mu.RLock()
|
lm.mu.RLock()
|
||||||
defer lm.mu.RUnlock()
|
defer lm.mu.RUnlock()
|
||||||
|
|
@ -134,16 +139,14 @@ func (lm *LayoutManager) ListLayouts() []LayoutInfo {
|
||||||
CreatedAt: l.CreatedAt, UpdatedAt: l.UpdatedAt,
|
CreatedAt: l.CreatedAt, UpdatedAt: l.UpdatedAt,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
sort.Slice(infos, func(i, j int) bool {
|
|
||||||
return infos[i].Name < infos[j].Name
|
|
||||||
})
|
|
||||||
return infos
|
return infos
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteLayout removes a layout by name.
|
// DeleteLayout removes a layout by name.
|
||||||
|
// Use: lm.DeleteLayout("coding")
|
||||||
func (lm *LayoutManager) DeleteLayout(name string) {
|
func (lm *LayoutManager) DeleteLayout(name string) {
|
||||||
lm.mu.Lock()
|
lm.mu.Lock()
|
||||||
delete(lm.layouts, name)
|
delete(lm.layouts, name)
|
||||||
lm.mu.Unlock()
|
lm.mu.Unlock()
|
||||||
lm.save()
|
lm.saveLayouts()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
|
// pkg/window/messages.go
|
||||||
package window
|
package window
|
||||||
|
|
||||||
|
// WindowInfo contains information about a window.
|
||||||
|
// Use: info := window.WindowInfo{Name: "editor", Title: "Core Editor"}
|
||||||
type WindowInfo struct {
|
type WindowInfo struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
|
@ -13,60 +16,113 @@ type WindowInfo struct {
|
||||||
Focused bool `json:"focused"`
|
Focused bool `json:"focused"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type QueryWindowList struct{}
|
// Bounds describes the position and size of a window.
|
||||||
|
// Use: bounds := window.Bounds{X: 10, Y: 10, Width: 1280, Height: 800}
|
||||||
type QueryWindowByName struct{ Name string }
|
type Bounds struct {
|
||||||
|
X int `json:"x"`
|
||||||
type QueryConfig struct{}
|
Y int `json:"y"`
|
||||||
|
Width int `json:"width"`
|
||||||
type QuerySavedWindowStates struct{}
|
Height int `json:"height"`
|
||||||
|
|
||||||
// Example: c.PERFORM(TaskOpenWindow{Window: Window{Name: "editor", URL: "/"}})
|
|
||||||
type TaskOpenWindow struct {
|
|
||||||
Window Window
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Queries (read-only) ---
|
||||||
|
|
||||||
|
// QueryWindowList returns all tracked windows. Result: []WindowInfo
|
||||||
|
// Use: result, _, err := c.QUERY(window.QueryWindowList{})
|
||||||
|
type QueryWindowList struct{}
|
||||||
|
|
||||||
|
// QueryWindowByName returns a single window by name. Result: *WindowInfo (nil if not found)
|
||||||
|
// Use: result, _, err := c.QUERY(window.QueryWindowByName{Name: "editor"})
|
||||||
|
type QueryWindowByName struct{ Name string }
|
||||||
|
|
||||||
|
// QueryConfig requests this service's config section from the display orchestrator.
|
||||||
|
// Result: map[string]any
|
||||||
|
// Use: result, _, err := c.QUERY(window.QueryConfig{})
|
||||||
|
type QueryConfig struct{}
|
||||||
|
|
||||||
|
// QueryWindowBounds returns the current bounds for a window.
|
||||||
|
// Use: result, _, err := c.QUERY(window.QueryWindowBounds{Name: "editor"})
|
||||||
|
type QueryWindowBounds struct{ Name string }
|
||||||
|
|
||||||
|
// QueryFindSpace returns a suggested free placement for a new window.
|
||||||
|
// Use: result, _, err := c.QUERY(window.QueryFindSpace{Width: 1280, Height: 800})
|
||||||
|
type QueryFindSpace struct {
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
ScreenWidth int
|
||||||
|
ScreenHeight int
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryLayoutSuggestion returns a layout recommendation for the current screen.
|
||||||
|
// Use: result, _, err := c.QUERY(window.QueryLayoutSuggestion{WindowCount: 2})
|
||||||
|
type QueryLayoutSuggestion struct {
|
||||||
|
WindowCount int
|
||||||
|
ScreenWidth int
|
||||||
|
ScreenHeight int
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tasks (side-effects) ---
|
||||||
|
|
||||||
|
// TaskOpenWindow creates a new window. Result: WindowInfo
|
||||||
|
// Use: _, _, err := c.PERFORM(window.TaskOpenWindow{Opts: []window.WindowOption{window.WithName("editor")}})
|
||||||
|
type TaskOpenWindow struct {
|
||||||
|
Window *Window
|
||||||
|
Opts []WindowOption
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskCloseWindow closes a window after persisting state.
|
||||||
|
// Platform close events emit ActionWindowClosed through the tracked window handler.
|
||||||
|
// Use: _, _, err := c.PERFORM(window.TaskCloseWindow{Name: "editor"})
|
||||||
type TaskCloseWindow struct{ Name string }
|
type TaskCloseWindow struct{ Name string }
|
||||||
|
|
||||||
// Example: c.PERFORM(TaskSetPosition{Name: "editor", X: 100, Y: 200})
|
// TaskSetPosition moves a window.
|
||||||
|
// Use: _, _, err := c.PERFORM(window.TaskSetPosition{Name: "editor", X: 160, Y: 120})
|
||||||
type TaskSetPosition struct {
|
type TaskSetPosition struct {
|
||||||
Name string
|
Name string
|
||||||
X, Y int
|
X, Y int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Example: c.PERFORM(TaskSetBounds{Name: "editor", X: 100, Y: 200, Width: 1280, Height: 720})
|
// TaskSetSize resizes a window.
|
||||||
type TaskSetBounds struct {
|
// Use: _, _, err := c.PERFORM(window.TaskSetSize{Name: "editor", Width: 1280, Height: 800})
|
||||||
Name string
|
|
||||||
X, Y int
|
|
||||||
Width, Height int
|
|
||||||
}
|
|
||||||
|
|
||||||
// Example: c.PERFORM(TaskSetSize{Name: "editor", Width: 1280, Height: 720})
|
|
||||||
type TaskSetSize struct {
|
type TaskSetSize struct {
|
||||||
Name string
|
Name string
|
||||||
Width, Height int
|
Width, Height int
|
||||||
|
// W and H are compatibility aliases for older call sites.
|
||||||
|
W, H int
|
||||||
}
|
}
|
||||||
|
|
||||||
// Example: c.PERFORM(TaskMaximize{Name: "editor"})
|
// TaskMaximise maximises a window.
|
||||||
type TaskMaximize struct{ Name string }
|
// Use: _, _, err := c.PERFORM(window.TaskMaximise{Name: "editor"})
|
||||||
|
type TaskMaximise struct{ Name string }
|
||||||
|
|
||||||
// Example: c.PERFORM(TaskMinimize{Name: "editor"})
|
// TaskMinimise minimises a window.
|
||||||
type TaskMinimize struct{ Name string }
|
// Use: _, _, err := c.PERFORM(window.TaskMinimise{Name: "editor"})
|
||||||
|
type TaskMinimise struct{ Name string }
|
||||||
|
|
||||||
|
// TaskFocus brings a window to the front.
|
||||||
|
// Use: _, _, err := c.PERFORM(window.TaskFocus{Name: "editor"})
|
||||||
type TaskFocus struct{ Name string }
|
type TaskFocus struct{ Name string }
|
||||||
|
|
||||||
|
// TaskRestore restores a maximised or minimised window to its normal state.
|
||||||
|
// Use: _, _, err := c.PERFORM(window.TaskRestore{Name: "editor"})
|
||||||
type TaskRestore struct{ Name string }
|
type TaskRestore struct{ Name string }
|
||||||
|
|
||||||
|
// TaskSetTitle changes a window's title.
|
||||||
|
// Use: _, _, err := c.PERFORM(window.TaskSetTitle{Name: "editor", Title: "Core Editor"})
|
||||||
type TaskSetTitle struct {
|
type TaskSetTitle struct {
|
||||||
Name string
|
Name string
|
||||||
Title string
|
Title string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TaskSetAlwaysOnTop pins a window above others.
|
||||||
|
// Use: _, _, err := c.PERFORM(window.TaskSetAlwaysOnTop{Name: "editor", AlwaysOnTop: true})
|
||||||
type TaskSetAlwaysOnTop struct {
|
type TaskSetAlwaysOnTop struct {
|
||||||
Name string
|
Name string
|
||||||
AlwaysOnTop bool
|
AlwaysOnTop bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TaskSetBackgroundColour updates the window background colour.
|
||||||
|
// Use: _, _, err := c.PERFORM(window.TaskSetBackgroundColour{Name: "editor", Red: 0, Green: 0, Blue: 0, Alpha: 0})
|
||||||
type TaskSetBackgroundColour struct {
|
type TaskSetBackgroundColour struct {
|
||||||
Name string
|
Name string
|
||||||
Red uint8
|
Red uint8
|
||||||
|
|
@ -75,78 +131,159 @@ type TaskSetBackgroundColour struct {
|
||||||
Alpha uint8
|
Alpha uint8
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TaskSetOpacity updates the window opacity as a value between 0 and 1.
|
||||||
|
// Use: _, _, err := c.PERFORM(window.TaskSetOpacity{Name: "editor", Opacity: 0.85})
|
||||||
|
type TaskSetOpacity struct {
|
||||||
|
Name string
|
||||||
|
Opacity float32
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskSetVisibility shows or hides a window.
|
||||||
|
// Use: _, _, err := c.PERFORM(window.TaskSetVisibility{Name: "editor", Visible: false})
|
||||||
type TaskSetVisibility struct {
|
type TaskSetVisibility struct {
|
||||||
Name string
|
Name string
|
||||||
Visible bool
|
Visible bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TaskFullscreen enters or exits fullscreen mode.
|
||||||
|
// Use: _, _, err := c.PERFORM(window.TaskFullscreen{Name: "editor", Fullscreen: true})
|
||||||
type TaskFullscreen struct {
|
type TaskFullscreen struct {
|
||||||
Name string
|
Name string
|
||||||
Fullscreen bool
|
Fullscreen bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Layout Queries ---
|
||||||
|
|
||||||
|
// QueryLayoutList returns summaries of all saved layouts. Result: []LayoutInfo
|
||||||
|
// Use: result, _, err := c.QUERY(window.QueryLayoutList{})
|
||||||
type QueryLayoutList struct{}
|
type QueryLayoutList struct{}
|
||||||
|
|
||||||
|
// QueryLayoutGet returns a layout by name. Result: *Layout (nil if not found)
|
||||||
|
// Use: result, _, err := c.QUERY(window.QueryLayoutGet{Name: "coding"})
|
||||||
type QueryLayoutGet struct{ Name string }
|
type QueryLayoutGet struct{ Name string }
|
||||||
|
|
||||||
// Example: c.PERFORM(TaskSaveLayout{Name: "coding"})
|
// --- Layout Tasks ---
|
||||||
|
|
||||||
|
// TaskSaveLayout saves the current window arrangement as a named layout. Result: bool
|
||||||
|
// Use: _, _, err := c.PERFORM(window.TaskSaveLayout{Name: "coding"})
|
||||||
type TaskSaveLayout struct{ Name string }
|
type TaskSaveLayout struct{ Name string }
|
||||||
|
|
||||||
// Example: c.PERFORM(TaskRestoreLayout{Name: "coding"})
|
// TaskRestoreLayout restores a saved layout by name.
|
||||||
|
// Use: _, _, err := c.PERFORM(window.TaskRestoreLayout{Name: "coding"})
|
||||||
type TaskRestoreLayout struct{ Name string }
|
type TaskRestoreLayout struct{ Name string }
|
||||||
|
|
||||||
// Example: c.PERFORM(TaskDeleteLayout{Name: "coding"})
|
// TaskDeleteLayout removes a saved layout by name.
|
||||||
|
// Use: _, _, err := c.PERFORM(window.TaskDeleteLayout{Name: "coding"})
|
||||||
type TaskDeleteLayout struct{ Name string }
|
type TaskDeleteLayout struct{ Name string }
|
||||||
|
|
||||||
// Example: c.PERFORM(TaskResetWindowState{})
|
// TaskTileWindows arranges windows in a tiling mode.
|
||||||
type TaskResetWindowState struct{}
|
// Use: _, _, err := c.PERFORM(window.TaskTileWindows{Mode: "grid"})
|
||||||
|
|
||||||
// Example: c.PERFORM(TaskTileWindows{Mode: "left-right", Windows: []string{"editor", "terminal"}})
|
|
||||||
type TaskTileWindows struct {
|
type TaskTileWindows struct {
|
||||||
Mode string // "left-right", "grid", "left-half", "right-half", etc.
|
Mode string // "left-right", "grid", "left-half", "right-half", etc.
|
||||||
Windows []string // window names; empty = all
|
Windows []string // window names; empty = all
|
||||||
}
|
}
|
||||||
|
|
||||||
// Example: c.PERFORM(TaskStackWindows{Windows: []string{"editor", "terminal"}, OffsetX: 24, OffsetY: 24})
|
// TaskSnapWindow snaps a window to a screen edge/corner.
|
||||||
type TaskStackWindows struct {
|
// Use: _, _, err := c.PERFORM(window.TaskSnapWindow{Name: "editor", Position: "left"})
|
||||||
Windows []string // window names; empty = all
|
|
||||||
OffsetX int
|
|
||||||
OffsetY int
|
|
||||||
}
|
|
||||||
|
|
||||||
// Example: c.PERFORM(TaskSnapWindow{Name: "editor", Position: "right"})
|
|
||||||
type TaskSnapWindow struct {
|
type TaskSnapWindow struct {
|
||||||
Name string // window name
|
Name string // window name
|
||||||
Position string // "left", "right", "top", "bottom", "top-left", "top-right", "bottom-left", "bottom-right", "center"
|
Position string // "left", "right", "top", "bottom", "top-left", "top-right", "bottom-left", "bottom-right", "center"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Example: c.PERFORM(TaskApplyWorkflow{Workflow: "coding"})
|
// TaskArrangePair places two windows side-by-side in a balanced split.
|
||||||
type TaskApplyWorkflow struct {
|
// Use: _, _, err := c.PERFORM(window.TaskArrangePair{First: "editor", Second: "terminal"})
|
||||||
Workflow string
|
type TaskArrangePair struct {
|
||||||
Windows []string // window names; empty = all
|
First string
|
||||||
|
Second string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Example: c.PERFORM(TaskSaveConfig{Config: map[string]any{"default_width": 800}})
|
// TaskBesideEditor places a target window beside an editor/IDE window.
|
||||||
type TaskSaveConfig struct{ Config map[string]any }
|
// Use: _, _, err := c.PERFORM(window.TaskBesideEditor{Editor: "editor", Window: "terminal"})
|
||||||
|
type TaskBesideEditor struct {
|
||||||
|
Editor string
|
||||||
|
Window string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskStackWindows cascades windows with a shared offset.
|
||||||
|
// Use: _, _, err := c.PERFORM(window.TaskStackWindows{Windows: []string{"editor", "terminal"}})
|
||||||
|
type TaskStackWindows struct {
|
||||||
|
Windows []string
|
||||||
|
OffsetX int
|
||||||
|
OffsetY int
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskApplyWorkflow applies a predefined workflow layout to windows.
|
||||||
|
// Use: _, _, err := c.PERFORM(window.TaskApplyWorkflow{Workflow: window.WorkflowCoding})
|
||||||
|
type TaskApplyWorkflow struct {
|
||||||
|
Workflow WorkflowLayout
|
||||||
|
Windows []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TaskSaveConfig persists this service's config section via the display orchestrator.
|
||||||
|
// Use: _, _, err := c.PERFORM(window.TaskSaveConfig{Value: map[string]any{"default_width": 1280}})
|
||||||
|
type TaskSaveConfig struct{ Value map[string]any }
|
||||||
|
|
||||||
|
// --- Actions (broadcasts) ---
|
||||||
|
|
||||||
|
// ActionWindowOpened is broadcast when a window is created.
|
||||||
|
// Use: _ = c.ACTION(window.ActionWindowOpened{Name: "editor"})
|
||||||
type ActionWindowOpened struct{ Name string }
|
type ActionWindowOpened struct{ Name string }
|
||||||
|
|
||||||
|
// ActionWindowClosed is broadcast when a window is closed.
|
||||||
|
// Use: _ = c.ACTION(window.ActionWindowClosed{Name: "editor"})
|
||||||
type ActionWindowClosed struct{ Name string }
|
type ActionWindowClosed struct{ Name string }
|
||||||
|
|
||||||
|
// ActionWindowMoved is broadcast when a window is moved.
|
||||||
|
// Use: _ = c.ACTION(window.ActionWindowMoved{Name: "editor", X: 160, Y: 120})
|
||||||
type ActionWindowMoved struct {
|
type ActionWindowMoved struct {
|
||||||
Name string
|
Name string
|
||||||
X, Y int
|
X, Y int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ActionWindowResized is broadcast when a window is resized.
|
||||||
|
// Use: _ = c.ACTION(window.ActionWindowResized{Name: "editor", Width: 1280, Height: 800})
|
||||||
type ActionWindowResized struct {
|
type ActionWindowResized struct {
|
||||||
Name string
|
Name string
|
||||||
Width, Height int
|
Width, Height int
|
||||||
|
// W and H are compatibility aliases for older listeners.
|
||||||
|
W, H int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ActionWindowFocused is broadcast when a window gains focus.
|
||||||
|
// Use: _ = c.ACTION(window.ActionWindowFocused{Name: "editor"})
|
||||||
type ActionWindowFocused struct{ Name string }
|
type ActionWindowFocused struct{ Name string }
|
||||||
|
|
||||||
|
// ActionWindowBlurred is broadcast when a window loses focus.
|
||||||
|
// Use: _ = c.ACTION(window.ActionWindowBlurred{Name: "editor"})
|
||||||
type ActionWindowBlurred struct{ Name string }
|
type ActionWindowBlurred struct{ Name string }
|
||||||
|
|
||||||
|
// ActionFilesDropped is broadcast when files are dropped onto a window.
|
||||||
|
// Use: _ = c.ACTION(window.ActionFilesDropped{Name: "editor", Paths: []string{"/tmp/report.pdf"}})
|
||||||
type ActionFilesDropped struct {
|
type ActionFilesDropped struct {
|
||||||
Name string `json:"name"` // window name
|
Name string `json:"name"` // window name
|
||||||
Paths []string `json:"paths"`
|
Paths []string `json:"paths"`
|
||||||
TargetID string `json:"targetId,omitempty"`
|
TargetID string `json:"targetId,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SpaceInfo describes a suggested empty area on the screen.
|
||||||
|
// Use: info := window.SpaceInfo{X: 160, Y: 120, Width: 1280, Height: 800}
|
||||||
|
type SpaceInfo struct {
|
||||||
|
X int `json:"x"`
|
||||||
|
Y int `json:"y"`
|
||||||
|
Width int `json:"width"`
|
||||||
|
Height int `json:"height"`
|
||||||
|
ScreenWidth int `json:"screenWidth"`
|
||||||
|
ScreenHeight int `json:"screenHeight"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LayoutSuggestion describes a recommended layout for a screen.
|
||||||
|
// Use: suggestion := window.LayoutSuggestion{Mode: "side-by-side"}
|
||||||
|
type LayoutSuggestion struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Columns int `json:"columns"`
|
||||||
|
Rows int `json:"rows"`
|
||||||
|
PrimaryWidth int `json:"primaryWidth"`
|
||||||
|
SecondaryWidth int `json:"secondaryWidth"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,36 @@
|
||||||
|
// pkg/window/mock_platform.go
|
||||||
package window
|
package window
|
||||||
|
|
||||||
// MockPlatform is an exported mock for cross-package integration tests.
|
// MockPlatform is an exported mock for cross-package integration tests.
|
||||||
|
// Use: platform := window.NewMockPlatform()
|
||||||
// For internal tests, use the unexported mockPlatform in mock_test.go.
|
// For internal tests, use the unexported mockPlatform in mock_test.go.
|
||||||
type MockPlatform struct {
|
type MockPlatform struct {
|
||||||
Windows []*MockWindow
|
Windows []*MockWindow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewMockPlatform creates a window platform mock.
|
||||||
|
// Use: platform := window.NewMockPlatform()
|
||||||
func NewMockPlatform() *MockPlatform {
|
func NewMockPlatform() *MockPlatform {
|
||||||
return &MockPlatform{}
|
return &MockPlatform{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockPlatform) CreateWindow(options PlatformWindowOptions) PlatformWindow {
|
// CreateWindow creates an in-memory window for tests.
|
||||||
|
// Use: w := platform.CreateWindow(window.PlatformWindowOptions{Name: "editor"})
|
||||||
|
func (m *MockPlatform) CreateWindow(opts PlatformWindowOptions) PlatformWindow {
|
||||||
w := &MockWindow{
|
w := &MockWindow{
|
||||||
name: options.Name, title: options.Title, url: options.URL,
|
name: opts.Name, title: opts.Title, url: opts.URL,
|
||||||
width: options.Width, height: options.Height,
|
width: opts.Width, height: opts.Height,
|
||||||
x: options.X, y: options.Y,
|
x: opts.X, y: opts.Y,
|
||||||
visible: !options.Hidden,
|
alwaysOnTop: opts.AlwaysOnTop,
|
||||||
|
backgroundColor: opts.BackgroundColour,
|
||||||
|
visible: !opts.Hidden,
|
||||||
}
|
}
|
||||||
m.Windows = append(m.Windows, w)
|
m.Windows = append(m.Windows, w)
|
||||||
return w
|
return w
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetWindows returns all tracked mock windows.
|
||||||
|
// Use: windows := platform.GetWindows()
|
||||||
func (m *MockPlatform) GetWindows() []PlatformWindow {
|
func (m *MockPlatform) GetWindows() []PlatformWindow {
|
||||||
out := make([]PlatformWindow, len(m.Windows))
|
out := make([]PlatformWindow, len(m.Windows))
|
||||||
for i, w := range m.Windows {
|
for i, w := range m.Windows {
|
||||||
|
|
@ -29,13 +39,16 @@ func (m *MockPlatform) GetWindows() []PlatformWindow {
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MockWindow is an in-memory window handle used by tests.
|
||||||
|
// Use: w := &window.MockWindow{}
|
||||||
type MockWindow struct {
|
type MockWindow struct {
|
||||||
name, title, url string
|
name, title, url string
|
||||||
width, height, x, y int
|
width, height, x, y int
|
||||||
maximised, minimised bool
|
maximised, minimised bool
|
||||||
focused bool
|
focused bool
|
||||||
visible, alwaysOnTop bool
|
visible, alwaysOnTop bool
|
||||||
backgroundColour [4]uint8
|
backgroundColor [4]uint8
|
||||||
|
opacity float32
|
||||||
closed bool
|
closed bool
|
||||||
eventHandlers []func(WindowEvent)
|
eventHandlers []func(WindowEvent)
|
||||||
fileDropHandlers []func(paths []string, targetID string)
|
fileDropHandlers []func(paths []string, targetID string)
|
||||||
|
|
@ -45,36 +58,46 @@ func (w *MockWindow) Name() string { return w.name }
|
||||||
func (w *MockWindow) Title() string { return w.title }
|
func (w *MockWindow) Title() string { return w.title }
|
||||||
func (w *MockWindow) Position() (int, int) { return w.x, w.y }
|
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) 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) IsVisible() bool { return w.visible }
|
||||||
|
func (w *MockWindow) IsMinimised() bool { return w.minimised }
|
||||||
|
func (w *MockWindow) IsMaximised() bool { return w.maximised }
|
||||||
func (w *MockWindow) IsFocused() bool { return w.focused }
|
func (w *MockWindow) IsFocused() bool { return w.focused }
|
||||||
func (w *MockWindow) SetTitle(title string) { w.title = title }
|
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) 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) 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) SetBackgroundColour(r, g, b, a uint8) { w.backgroundColor = [4]uint8{r, g, b, a} }
|
||||||
|
func (w *MockWindow) SetOpacity(opacity float32) { w.opacity = opacity }
|
||||||
func (w *MockWindow) SetVisibility(visible bool) { w.visible = visible }
|
func (w *MockWindow) SetVisibility(visible bool) { w.visible = visible }
|
||||||
func (w *MockWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop }
|
func (w *MockWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop }
|
||||||
func (w *MockWindow) Maximise() { w.maximised = true; w.minimised = false }
|
func (w *MockWindow) Maximise() { w.maximised = true; w.minimised = false; w.visible = true }
|
||||||
func (w *MockWindow) Restore() { w.maximised = false; w.minimised = false }
|
func (w *MockWindow) Restore() { w.maximised = false; w.minimised = false; w.visible = true }
|
||||||
func (w *MockWindow) Minimise() { w.maximised = false; w.minimised = true }
|
func (w *MockWindow) Minimise() { w.minimised = true; w.maximised = false; w.visible = false }
|
||||||
func (w *MockWindow) Focus() { w.focused = true }
|
func (w *MockWindow) Focus() { w.focused = true }
|
||||||
func (w *MockWindow) Close() {
|
func (w *MockWindow) Close() {
|
||||||
w.closed = true
|
w.closed = true
|
||||||
for _, handler := range w.eventHandlers {
|
w.emit(WindowEvent{Type: "close", Name: w.name})
|
||||||
handler(WindowEvent{Type: "close", Name: w.name})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
func (w *MockWindow) Show() { w.visible = true }
|
func (w *MockWindow) Show() { w.visible = true }
|
||||||
func (w *MockWindow) Hide() { w.visible = false }
|
func (w *MockWindow) Hide() { w.visible = false }
|
||||||
func (w *MockWindow) Fullscreen() {}
|
func (w *MockWindow) Fullscreen() {}
|
||||||
func (w *MockWindow) UnFullscreen() {}
|
func (w *MockWindow) UnFullscreen() {}
|
||||||
|
func (w *MockWindow) OpenDevTools() {}
|
||||||
|
func (w *MockWindow) CloseDevTools() {}
|
||||||
func (w *MockWindow) OnWindowEvent(handler func(WindowEvent)) {
|
func (w *MockWindow) OnWindowEvent(handler func(WindowEvent)) {
|
||||||
w.eventHandlers = append(w.eventHandlers, handler)
|
w.eventHandlers = append(w.eventHandlers, handler)
|
||||||
}
|
}
|
||||||
func (w *MockWindow) OnFileDrop(handler func(paths []string, targetID string)) {
|
func (w *MockWindow) OnFileDrop(handler func(paths []string, targetID string)) {
|
||||||
w.fileDropHandlers = append(w.fileDropHandlers, handler)
|
w.fileDropHandlers = append(w.fileDropHandlers, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (w *MockWindow) emit(e WindowEvent) {
|
||||||
|
for _, h := range w.eventHandlers {
|
||||||
|
h(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *MockWindow) emitFileDrop(paths []string, targetID string) {
|
||||||
|
for _, h := range w.fileDropHandlers {
|
||||||
|
h(paths, targetID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
// pkg/window/mock_test.go
|
||||||
package window
|
package window
|
||||||
|
|
||||||
type mockPlatform struct {
|
type mockPlatform struct {
|
||||||
|
|
@ -8,12 +9,14 @@ func newMockPlatform() *mockPlatform {
|
||||||
return &mockPlatform{}
|
return &mockPlatform{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *mockPlatform) CreateWindow(options PlatformWindowOptions) PlatformWindow {
|
func (m *mockPlatform) CreateWindow(opts PlatformWindowOptions) PlatformWindow {
|
||||||
w := &mockWindow{
|
w := &mockWindow{
|
||||||
name: options.Name, title: options.Title, url: options.URL,
|
name: opts.Name, title: opts.Title, url: opts.URL,
|
||||||
width: options.Width, height: options.Height,
|
width: opts.Width, height: opts.Height,
|
||||||
x: options.X, y: options.Y,
|
x: opts.X, y: opts.Y,
|
||||||
visible: !options.Hidden,
|
alwaysOnTop: opts.AlwaysOnTop,
|
||||||
|
backgroundColor: opts.BackgroundColour,
|
||||||
|
visible: !opts.Hidden,
|
||||||
}
|
}
|
||||||
m.windows = append(m.windows, w)
|
m.windows = append(m.windows, w)
|
||||||
return w
|
return w
|
||||||
|
|
@ -33,9 +36,10 @@ type mockWindow struct {
|
||||||
maximised, minimised bool
|
maximised, minimised bool
|
||||||
focused bool
|
focused bool
|
||||||
visible, alwaysOnTop bool
|
visible, alwaysOnTop bool
|
||||||
backgroundColour [4]uint8
|
backgroundColor [4]uint8
|
||||||
|
opacity float32
|
||||||
|
devtoolsOpen bool
|
||||||
closed bool
|
closed bool
|
||||||
fullscreened bool
|
|
||||||
eventHandlers []func(WindowEvent)
|
eventHandlers []func(WindowEvent)
|
||||||
fileDropHandlers []func(paths []string, targetID string)
|
fileDropHandlers []func(paths []string, targetID string)
|
||||||
}
|
}
|
||||||
|
|
@ -44,22 +48,20 @@ func (w *mockWindow) Name() string { return w.name }
|
||||||
func (w *mockWindow) Title() string { return w.title }
|
func (w *mockWindow) Title() string { return w.title }
|
||||||
func (w *mockWindow) Position() (int, int) { return w.x, w.y }
|
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) 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) IsVisible() bool { return w.visible }
|
||||||
|
func (w *mockWindow) IsMinimised() bool { return w.minimised }
|
||||||
|
func (w *mockWindow) IsMaximised() bool { return w.maximised }
|
||||||
func (w *mockWindow) IsFocused() bool { return w.focused }
|
func (w *mockWindow) IsFocused() bool { return w.focused }
|
||||||
func (w *mockWindow) SetTitle(title string) { w.title = title }
|
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) 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) 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) SetBackgroundColour(r, g, b, a uint8) { w.backgroundColor = [4]uint8{r, g, b, a} }
|
||||||
|
func (w *mockWindow) SetOpacity(opacity float32) { w.opacity = opacity }
|
||||||
func (w *mockWindow) SetVisibility(visible bool) { w.visible = visible }
|
func (w *mockWindow) SetVisibility(visible bool) { w.visible = visible }
|
||||||
func (w *mockWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop }
|
func (w *mockWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop }
|
||||||
func (w *mockWindow) Maximise() { w.maximised = true; w.minimised = false }
|
func (w *mockWindow) Maximise() { w.maximised = true; w.minimised = false; w.visible = true }
|
||||||
func (w *mockWindow) Restore() { w.maximised = false; w.minimised = false }
|
func (w *mockWindow) Restore() { w.maximised = false; w.minimised = false; w.visible = true }
|
||||||
func (w *mockWindow) Minimise() { w.maximised = false; w.minimised = true }
|
func (w *mockWindow) Minimise() { w.minimised = true; w.maximised = false; w.visible = false }
|
||||||
func (w *mockWindow) Focus() { w.focused = true }
|
func (w *mockWindow) Focus() { w.focused = true }
|
||||||
func (w *mockWindow) Close() {
|
func (w *mockWindow) Close() {
|
||||||
w.closed = true
|
w.closed = true
|
||||||
|
|
@ -67,8 +69,10 @@ func (w *mockWindow) Close() {
|
||||||
}
|
}
|
||||||
func (w *mockWindow) Show() { w.visible = true }
|
func (w *mockWindow) Show() { w.visible = true }
|
||||||
func (w *mockWindow) Hide() { w.visible = false }
|
func (w *mockWindow) Hide() { w.visible = false }
|
||||||
func (w *mockWindow) Fullscreen() { w.fullscreened = true }
|
func (w *mockWindow) Fullscreen() {}
|
||||||
func (w *mockWindow) UnFullscreen() { w.fullscreened = false }
|
func (w *mockWindow) UnFullscreen() {}
|
||||||
|
func (w *mockWindow) OpenDevTools() { w.devtoolsOpen = true }
|
||||||
|
func (w *mockWindow) CloseDevTools() { w.devtoolsOpen = false }
|
||||||
func (w *mockWindow) OnWindowEvent(handler func(WindowEvent)) {
|
func (w *mockWindow) OnWindowEvent(handler func(WindowEvent)) {
|
||||||
w.eventHandlers = append(w.eventHandlers, handler)
|
w.eventHandlers = append(w.eventHandlers, handler)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
92
pkg/window/options.go
Normal file
92
pkg/window/options.go
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
// 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.
|
||||||
|
// Use: w, err := window.ApplyOptions(window.WithName("editor"), window.WithURL("/editor"))
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithName sets the window name.
|
||||||
|
// Use: window.WithName("editor")
|
||||||
|
func WithName(name string) WindowOption {
|
||||||
|
return func(w *Window) error { w.Name = name; return nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithTitle sets the window title.
|
||||||
|
// Use: window.WithTitle("Core Editor")
|
||||||
|
func WithTitle(title string) WindowOption {
|
||||||
|
return func(w *Window) error { w.Title = title; return nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithURL sets the initial window URL.
|
||||||
|
// Use: window.WithURL("/editor")
|
||||||
|
func WithURL(url string) WindowOption {
|
||||||
|
return func(w *Window) error { w.URL = url; return nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithSize sets the initial window size.
|
||||||
|
// Use: window.WithSize(1280, 800)
|
||||||
|
func WithSize(width, height int) WindowOption {
|
||||||
|
return func(w *Window) error { w.Width = width; w.Height = height; return nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPosition sets the initial window position.
|
||||||
|
// Use: window.WithPosition(160, 120)
|
||||||
|
func WithPosition(x, y int) WindowOption {
|
||||||
|
return func(w *Window) error { w.X = x; w.Y = y; return nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithMinSize sets the minimum window size.
|
||||||
|
// Use: window.WithMinSize(640, 480)
|
||||||
|
func WithMinSize(width, height int) WindowOption {
|
||||||
|
return func(w *Window) error { w.MinWidth = width; w.MinHeight = height; return nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithMaxSize sets the maximum window size.
|
||||||
|
// Use: window.WithMaxSize(1920, 1080)
|
||||||
|
func WithMaxSize(width, height int) WindowOption {
|
||||||
|
return func(w *Window) error { w.MaxWidth = width; w.MaxHeight = height; return nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithFrameless toggles the native window frame.
|
||||||
|
// Use: window.WithFrameless(true)
|
||||||
|
func WithFrameless(frameless bool) WindowOption {
|
||||||
|
return func(w *Window) error { w.Frameless = frameless; return nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithHidden starts the window hidden.
|
||||||
|
// Use: window.WithHidden(true)
|
||||||
|
func WithHidden(hidden bool) WindowOption {
|
||||||
|
return func(w *Window) error { w.Hidden = hidden; return nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithAlwaysOnTop keeps the window above other windows.
|
||||||
|
// Use: window.WithAlwaysOnTop(true)
|
||||||
|
func WithAlwaysOnTop(alwaysOnTop bool) WindowOption {
|
||||||
|
return func(w *Window) error { w.AlwaysOnTop = alwaysOnTop; return nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithBackgroundColour sets the window background colour with alpha.
|
||||||
|
// Use: window.WithBackgroundColour(0, 0, 0, 0)
|
||||||
|
func WithBackgroundColour(r, g, b, a uint8) WindowOption {
|
||||||
|
return func(w *Window) error { w.BackgroundColour = [4]uint8{r, g, b, a}; return nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithFileDrop enables drag-and-drop file handling.
|
||||||
|
// Use: window.WithFileDrop(true)
|
||||||
|
func WithFileDrop(enabled bool) WindowOption {
|
||||||
|
return func(w *Window) error { w.EnableFileDrop = enabled; return nil }
|
||||||
|
}
|
||||||
|
|
@ -1,375 +0,0 @@
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
@ -2,12 +2,14 @@
|
||||||
package window
|
package window
|
||||||
|
|
||||||
// Platform abstracts the windowing backend (Wails v3).
|
// Platform abstracts the windowing backend (Wails v3).
|
||||||
|
// Use: var p window.Platform
|
||||||
type Platform interface {
|
type Platform interface {
|
||||||
CreateWindow(options PlatformWindowOptions) PlatformWindow
|
CreateWindow(opts PlatformWindowOptions) PlatformWindow
|
||||||
GetWindows() []PlatformWindow
|
GetWindows() []PlatformWindow
|
||||||
}
|
}
|
||||||
|
|
||||||
// PlatformWindowOptions are the backend-specific options passed to CreateWindow.
|
// PlatformWindowOptions are the backend-specific options passed to CreateWindow.
|
||||||
|
// Use: opts := window.PlatformWindowOptions{Name: "editor", URL: "/editor"}
|
||||||
type PlatformWindowOptions struct {
|
type PlatformWindowOptions struct {
|
||||||
Name string
|
Name string
|
||||||
Title string
|
Title string
|
||||||
|
|
@ -25,6 +27,7 @@ type PlatformWindowOptions struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// PlatformWindow is a live window handle from the backend.
|
// PlatformWindow is a live window handle from the backend.
|
||||||
|
// Use: var w window.PlatformWindow
|
||||||
type PlatformWindow interface {
|
type PlatformWindow interface {
|
||||||
// Identity
|
// Identity
|
||||||
Name() string
|
Name() string
|
||||||
|
|
@ -33,17 +36,17 @@ type PlatformWindow interface {
|
||||||
// Queries
|
// Queries
|
||||||
Position() (int, int)
|
Position() (int, int)
|
||||||
Size() (int, int)
|
Size() (int, int)
|
||||||
IsMaximised() bool
|
|
||||||
IsMinimised() bool
|
|
||||||
IsVisible() bool
|
IsVisible() bool
|
||||||
|
IsMinimised() bool
|
||||||
|
IsMaximised() bool
|
||||||
IsFocused() bool
|
IsFocused() bool
|
||||||
|
|
||||||
// Mutations
|
// Mutations
|
||||||
SetTitle(title string)
|
SetTitle(title string)
|
||||||
SetBounds(x, y, width, height int)
|
|
||||||
SetPosition(x, y int)
|
SetPosition(x, y int)
|
||||||
SetSize(width, height int)
|
SetSize(width, height int)
|
||||||
SetBackgroundColour(r, g, b, a uint8)
|
SetBackgroundColour(r, g, b, a uint8)
|
||||||
|
SetOpacity(opacity float32)
|
||||||
SetVisibility(visible bool)
|
SetVisibility(visible bool)
|
||||||
SetAlwaysOnTop(alwaysOnTop bool)
|
SetAlwaysOnTop(alwaysOnTop bool)
|
||||||
|
|
||||||
|
|
@ -57,6 +60,8 @@ type PlatformWindow interface {
|
||||||
Hide()
|
Hide()
|
||||||
Fullscreen()
|
Fullscreen()
|
||||||
UnFullscreen()
|
UnFullscreen()
|
||||||
|
OpenDevTools()
|
||||||
|
CloseDevTools()
|
||||||
|
|
||||||
// Events
|
// Events
|
||||||
OnWindowEvent(handler func(event WindowEvent))
|
OnWindowEvent(handler func(event WindowEvent))
|
||||||
|
|
@ -66,6 +71,7 @@ type PlatformWindow interface {
|
||||||
}
|
}
|
||||||
|
|
||||||
// WindowEvent is emitted by the backend for window state changes.
|
// WindowEvent is emitted by the backend for window state changes.
|
||||||
|
// Use: evt := window.WindowEvent{Type: "focus", Name: "editor"}
|
||||||
type WindowEvent struct {
|
type WindowEvent struct {
|
||||||
Type string // "focus", "blur", "move", "resize", "close"
|
Type string // "focus", "blur", "move", "resize", "close"
|
||||||
Name string // window name
|
Name string // window name
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
|
// pkg/window/register.go
|
||||||
package window
|
package window
|
||||||
|
|
||||||
import "forge.lthn.ai/core/go/pkg/core"
|
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).
|
||||||
|
// Use: core.WithService(window.Register(platform))
|
||||||
func Register(p Platform) func(*core.Core) (any, error) {
|
func Register(p Platform) func(*core.Core) (any, error) {
|
||||||
return func(c *core.Core) (any, error) {
|
return func(c *core.Core) (any, error) {
|
||||||
return &Service{
|
return &Service{
|
||||||
|
|
|
||||||
|
|
@ -1,74 +1,86 @@
|
||||||
|
// pkg/window/service.go
|
||||||
package window
|
package window
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
coreerr "forge.lthn.ai/core/go-log"
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
"forge.lthn.ai/core/go/pkg/core"
|
||||||
"forge.lthn.ai/core/gui/pkg/screen"
|
"forge.lthn.ai/core/gui/pkg/screen"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Options holds configuration for the window service.
|
||||||
|
// Use: svc, err := window.Register(platform)(core.New())
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
|
|
||||||
|
// Service is a core.Service managing window lifecycle via IPC.
|
||||||
|
// Use: core.WithService(window.Register(window.NewMockPlatform()))
|
||||||
|
// It embeds ServiceRuntime for Core access and composes Manager for platform operations.
|
||||||
|
// Use: svc, err := window.Register(platform)(core.New())
|
||||||
type Service struct {
|
type Service struct {
|
||||||
*core.ServiceRuntime[Options]
|
*core.ServiceRuntime[Options]
|
||||||
manager *Manager
|
manager *Manager
|
||||||
platform Platform
|
platform Platform
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OnStartup queries config from the display orchestrator and registers IPC handlers.
|
||||||
|
// Use: _ = svc.OnStartup(context.Background())
|
||||||
func (s *Service) OnStartup(ctx context.Context) error {
|
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.
|
// If display is not registered, handled=false and we skip config.
|
||||||
configValue, handled, _ := s.Core().QUERY(QueryConfig{})
|
cfg, handled, _ := s.Core().QUERY(QueryConfig{})
|
||||||
if handled {
|
if handled {
|
||||||
if windowConfig, ok := configValue.(map[string]any); ok {
|
if wCfg, ok := cfg.(map[string]any); ok {
|
||||||
s.applyConfig(windowConfig)
|
s.applyConfig(wCfg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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().RegisterQuery(s.handleQuery)
|
||||||
s.Core().RegisterTask(s.handleTask)
|
s.Core().RegisterTask(s.handleTask)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) applyConfig(configData map[string]any) {
|
func (s *Service) applyConfig(cfg map[string]any) {
|
||||||
if width, ok := configData["default_width"]; ok {
|
if width, ok := cfg["default_width"]; ok {
|
||||||
if width, ok := width.(int); ok {
|
if width, ok := width.(int); ok {
|
||||||
s.manager.SetDefaultWidth(width)
|
s.manager.SetDefaultWidth(width)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if height, ok := configData["default_height"]; ok {
|
if height, ok := cfg["default_height"]; ok {
|
||||||
if height, ok := height.(int); ok {
|
if height, ok := height.(int); ok {
|
||||||
s.manager.SetDefaultHeight(height)
|
s.manager.SetDefaultHeight(height)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if stateFile, ok := configData["state_file"]; ok {
|
if stateFile, ok := cfg["state_file"]; ok {
|
||||||
if stateFile, ok := stateFile.(string); ok {
|
if stateFile, ok := stateFile.(string); ok {
|
||||||
s.manager.State().SetPath(stateFile)
|
s.manager.State().SetPath(stateFile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) requireWindow(name string, operation string) (PlatformWindow, error) {
|
// HandleIPCEvents is auto-discovered and registered by core.WithService.
|
||||||
platformWindow, ok := s.manager.Get(name)
|
// Use: _ = svc.HandleIPCEvents(core, msg)
|
||||||
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 {
|
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Query Handlers ---
|
||||||
|
|
||||||
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
||||||
switch q := q.(type) {
|
switch q := q.(type) {
|
||||||
case QueryWindowList:
|
case QueryWindowList:
|
||||||
return s.queryWindowList(), true, nil
|
return s.queryWindowList(), true, nil
|
||||||
case QueryWindowByName:
|
case QueryWindowByName:
|
||||||
return s.queryWindowByName(q.Name), true, nil
|
return s.queryWindowByName(q.Name), true, nil
|
||||||
case QuerySavedWindowStates:
|
case QueryWindowBounds:
|
||||||
return s.querySavedWindowStates(), true, nil
|
if info := s.queryWindowByName(q.Name); info != nil {
|
||||||
|
return &Bounds{X: info.X, Y: info.Y, Width: info.Width, Height: info.Height}, true, nil
|
||||||
|
}
|
||||||
|
return (*Bounds)(nil), true, nil
|
||||||
case QueryLayoutList:
|
case QueryLayoutList:
|
||||||
return s.manager.Layout().ListLayouts(), true, nil
|
return s.manager.Layout().ListLayouts(), true, nil
|
||||||
case QueryLayoutGet:
|
case QueryLayoutGet:
|
||||||
|
|
@ -77,6 +89,18 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
||||||
return (*Layout)(nil), true, nil
|
return (*Layout)(nil), true, nil
|
||||||
}
|
}
|
||||||
return &l, true, nil
|
return &l, true, nil
|
||||||
|
case QueryFindSpace:
|
||||||
|
screenW, screenH := q.ScreenWidth, q.ScreenHeight
|
||||||
|
if screenW <= 0 || screenH <= 0 {
|
||||||
|
screenW, screenH = s.primaryScreenSize()
|
||||||
|
}
|
||||||
|
return s.manager.FindSpace(screenW, screenH, q.Width, q.Height), true, nil
|
||||||
|
case QueryLayoutSuggestion:
|
||||||
|
screenW, screenH := q.ScreenWidth, q.ScreenHeight
|
||||||
|
if screenW <= 0 || screenH <= 0 {
|
||||||
|
screenW, screenH = s.primaryScreenSize()
|
||||||
|
}
|
||||||
|
return s.manager.SuggestLayout(screenW, screenH, q.WindowCount), true, nil
|
||||||
default:
|
default:
|
||||||
return nil, false, nil
|
return nil, false, nil
|
||||||
}
|
}
|
||||||
|
|
@ -86,20 +110,20 @@ func (s *Service) queryWindowList() []WindowInfo {
|
||||||
names := s.manager.List()
|
names := s.manager.List()
|
||||||
result := make([]WindowInfo, 0, len(names))
|
result := make([]WindowInfo, 0, len(names))
|
||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
if platformWindow, ok := s.manager.Get(name); ok {
|
if pw, ok := s.manager.Get(name); ok {
|
||||||
x, y := platformWindow.Position()
|
x, y := pw.Position()
|
||||||
width, height := platformWindow.Size()
|
w, h := pw.Size()
|
||||||
result = append(result, WindowInfo{
|
result = append(result, WindowInfo{
|
||||||
Name: name,
|
Name: name,
|
||||||
Title: platformWindow.Title(),
|
Title: pw.Title(),
|
||||||
X: x,
|
X: x,
|
||||||
Y: y,
|
Y: y,
|
||||||
Width: width,
|
Width: w,
|
||||||
Height: height,
|
Height: h,
|
||||||
Visible: platformWindow.IsVisible(),
|
Visible: pw.IsVisible(),
|
||||||
Minimized: platformWindow.IsMinimised(),
|
Minimized: pw.IsMinimised(),
|
||||||
Maximized: platformWindow.IsMaximised(),
|
Maximized: pw.IsMaximised(),
|
||||||
Focused: platformWindow.IsFocused(),
|
Focused: pw.IsFocused(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -107,37 +131,26 @@ func (s *Service) queryWindowList() []WindowInfo {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) queryWindowByName(name string) *WindowInfo {
|
func (s *Service) queryWindowByName(name string) *WindowInfo {
|
||||||
platformWindow, ok := s.manager.Get(name)
|
pw, ok := s.manager.Get(name)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
x, y := platformWindow.Position()
|
x, y := pw.Position()
|
||||||
width, height := platformWindow.Size()
|
w, h := pw.Size()
|
||||||
return &WindowInfo{
|
return &WindowInfo{
|
||||||
Name: name,
|
Name: name,
|
||||||
Title: platformWindow.Title(),
|
Title: pw.Title(),
|
||||||
X: x,
|
X: x,
|
||||||
Y: y,
|
Y: y,
|
||||||
Width: width,
|
Width: w,
|
||||||
Height: height,
|
Height: h,
|
||||||
Visible: platformWindow.IsVisible(),
|
Visible: pw.IsVisible(),
|
||||||
Minimized: platformWindow.IsMinimised(),
|
Minimized: pw.IsMinimised(),
|
||||||
Maximized: platformWindow.IsMaximised(),
|
Maximized: pw.IsMaximised(),
|
||||||
Focused: platformWindow.IsFocused(),
|
Focused: pw.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 ---
|
// --- Task Handlers ---
|
||||||
|
|
||||||
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
||||||
|
|
@ -148,14 +161,12 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
||||||
return nil, true, s.taskCloseWindow(t.Name)
|
return nil, true, s.taskCloseWindow(t.Name)
|
||||||
case TaskSetPosition:
|
case TaskSetPosition:
|
||||||
return nil, true, s.taskSetPosition(t.Name, t.X, t.Y)
|
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:
|
case TaskSetSize:
|
||||||
return nil, true, s.taskSetSize(t.Name, t.Width, t.Height)
|
return nil, true, s.taskSetSize(t.Name, t.Width, t.Height, t.W, t.H)
|
||||||
case TaskMaximize:
|
case TaskMaximise:
|
||||||
return nil, true, s.taskMaximize(t.Name)
|
return nil, true, s.taskMaximise(t.Name)
|
||||||
case TaskMinimize:
|
case TaskMinimise:
|
||||||
return nil, true, s.taskMinimize(t.Name)
|
return nil, true, s.taskMinimise(t.Name)
|
||||||
case TaskFocus:
|
case TaskFocus:
|
||||||
return nil, true, s.taskFocus(t.Name)
|
return nil, true, s.taskFocus(t.Name)
|
||||||
case TaskRestore:
|
case TaskRestore:
|
||||||
|
|
@ -166,6 +177,8 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
||||||
return nil, true, s.taskSetAlwaysOnTop(t.Name, t.AlwaysOnTop)
|
return nil, true, s.taskSetAlwaysOnTop(t.Name, t.AlwaysOnTop)
|
||||||
case TaskSetBackgroundColour:
|
case TaskSetBackgroundColour:
|
||||||
return nil, true, s.taskSetBackgroundColour(t.Name, t.Red, t.Green, t.Blue, t.Alpha)
|
return nil, true, s.taskSetBackgroundColour(t.Name, t.Red, t.Green, t.Blue, t.Alpha)
|
||||||
|
case TaskSetOpacity:
|
||||||
|
return nil, true, s.taskSetOpacity(t.Name, t.Opacity)
|
||||||
case TaskSetVisibility:
|
case TaskSetVisibility:
|
||||||
return nil, true, s.taskSetVisibility(t.Name, t.Visible)
|
return nil, true, s.taskSetVisibility(t.Name, t.Visible)
|
||||||
case TaskFullscreen:
|
case TaskFullscreen:
|
||||||
|
|
@ -177,15 +190,16 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
||||||
case TaskDeleteLayout:
|
case TaskDeleteLayout:
|
||||||
s.manager.Layout().DeleteLayout(t.Name)
|
s.manager.Layout().DeleteLayout(t.Name)
|
||||||
return nil, true, nil
|
return nil, true, nil
|
||||||
case TaskResetWindowState:
|
|
||||||
s.manager.State().Clear()
|
|
||||||
return nil, true, nil
|
|
||||||
case TaskTileWindows:
|
case TaskTileWindows:
|
||||||
return nil, true, s.taskTileWindows(t.Mode, t.Windows)
|
return nil, true, s.taskTileWindows(t.Mode, t.Windows)
|
||||||
case TaskStackWindows:
|
|
||||||
return nil, true, s.taskStackWindows(t.Windows, t.OffsetX, t.OffsetY)
|
|
||||||
case TaskSnapWindow:
|
case TaskSnapWindow:
|
||||||
return nil, true, s.taskSnapWindow(t.Name, t.Position)
|
return nil, true, s.taskSnapWindow(t.Name, t.Position)
|
||||||
|
case TaskArrangePair:
|
||||||
|
return nil, true, s.taskArrangePair(t.First, t.Second)
|
||||||
|
case TaskBesideEditor:
|
||||||
|
return nil, true, s.taskBesideEditor(t.Editor, t.Window)
|
||||||
|
case TaskStackWindows:
|
||||||
|
return nil, true, s.taskStackWindows(t.Windows, t.OffsetX, t.OffsetY)
|
||||||
case TaskApplyWorkflow:
|
case TaskApplyWorkflow:
|
||||||
return nil, true, s.taskApplyWorkflow(t.Workflow, t.Windows)
|
return nil, true, s.taskApplyWorkflow(t.Workflow, t.Windows)
|
||||||
default:
|
default:
|
||||||
|
|
@ -193,94 +207,70 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
func (s *Service) taskOpenWindow(t TaskOpenWindow) (any, bool, error) {
|
||||||
platformWindow, err := s.manager.Create(t.Window)
|
var (
|
||||||
|
pw PlatformWindow
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if t.Window != nil {
|
||||||
|
spec := *t.Window
|
||||||
|
pw, err = s.manager.Create(&spec)
|
||||||
|
} else {
|
||||||
|
pw, err = s.manager.Open(t.Opts...)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, true, err
|
return nil, true, err
|
||||||
}
|
}
|
||||||
x, y := platformWindow.Position()
|
x, y := pw.Position()
|
||||||
width, height := platformWindow.Size()
|
w, h := pw.Size()
|
||||||
info := WindowInfo{
|
info := WindowInfo{
|
||||||
Name: platformWindow.Name(),
|
Name: pw.Name(),
|
||||||
Title: platformWindow.Title(),
|
Title: pw.Title(),
|
||||||
X: x,
|
X: x,
|
||||||
Y: y,
|
Y: y,
|
||||||
Width: width,
|
Width: w,
|
||||||
Height: height,
|
Height: h,
|
||||||
Visible: platformWindow.IsVisible(),
|
Visible: pw.IsVisible(),
|
||||||
Minimized: platformWindow.IsMinimised(),
|
Minimized: pw.IsMinimised(),
|
||||||
Maximized: platformWindow.IsMaximised(),
|
Maximized: pw.IsMaximised(),
|
||||||
Focused: platformWindow.IsFocused(),
|
Focused: pw.IsFocused(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attach platform event listeners that convert to IPC actions
|
// Attach platform event listeners that convert to IPC actions
|
||||||
s.trackWindow(platformWindow)
|
s.trackWindow(pw)
|
||||||
|
|
||||||
// Broadcast to all listeners
|
// Broadcast to all listeners
|
||||||
_ = s.Core().ACTION(ActionWindowOpened{Name: platformWindow.Name()})
|
_ = s.Core().ACTION(ActionWindowOpened{Name: pw.Name()})
|
||||||
return info, true, nil
|
return info, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// trackWindow attaches platform event listeners that emit IPC actions.
|
// trackWindow attaches platform event listeners that emit IPC actions.
|
||||||
func (s *Service) trackWindow(platformWindow PlatformWindow) {
|
func (s *Service) trackWindow(pw PlatformWindow) {
|
||||||
platformWindow.OnWindowEvent(func(event WindowEvent) {
|
pw.OnWindowEvent(func(e WindowEvent) {
|
||||||
switch event.Type {
|
switch e.Type {
|
||||||
case "focus":
|
case "focus":
|
||||||
_ = s.Core().ACTION(ActionWindowFocused{Name: event.Name})
|
_ = s.Core().ACTION(ActionWindowFocused{Name: e.Name})
|
||||||
case "blur":
|
case "blur":
|
||||||
_ = s.Core().ACTION(ActionWindowBlurred{Name: event.Name})
|
_ = s.Core().ACTION(ActionWindowBlurred{Name: e.Name})
|
||||||
case "move":
|
case "move":
|
||||||
if data := event.Data; data != nil {
|
if data := e.Data; data != nil {
|
||||||
x, _ := data["x"].(int)
|
x, _ := data["x"].(int)
|
||||||
y, _ := data["y"].(int)
|
y, _ := data["y"].(int)
|
||||||
_ = s.Core().ACTION(ActionWindowMoved{Name: event.Name, X: x, Y: y})
|
_ = s.Core().ACTION(ActionWindowMoved{Name: e.Name, X: x, Y: y})
|
||||||
}
|
}
|
||||||
case "resize":
|
case "resize":
|
||||||
if data := event.Data; data != nil {
|
if data := e.Data; data != nil {
|
||||||
width, _ := data["width"].(int)
|
w, _ := data["w"].(int)
|
||||||
height, _ := data["height"].(int)
|
h, _ := data["h"].(int)
|
||||||
_ = s.Core().ACTION(ActionWindowResized{Name: event.Name, Width: width, Height: height})
|
_ = s.Core().ACTION(ActionWindowResized{Name: e.Name, Width: w, Height: h, W: w, H: h})
|
||||||
}
|
}
|
||||||
case "close":
|
case "close":
|
||||||
_ = s.Core().ACTION(ActionWindowClosed{Name: event.Name})
|
_ = s.Core().ACTION(ActionWindowClosed{Name: e.Name})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
platformWindow.OnFileDrop(func(paths []string, targetID string) {
|
pw.OnFileDrop(func(paths []string, targetID string) {
|
||||||
_ = s.Core().ACTION(ActionFilesDropped{
|
_ = s.Core().ACTION(ActionFilesDropped{
|
||||||
Name: platformWindow.Name(),
|
Name: pw.Name(),
|
||||||
Paths: paths,
|
Paths: paths,
|
||||||
TargetID: targetID,
|
TargetID: targetID,
|
||||||
})
|
})
|
||||||
|
|
@ -288,130 +278,142 @@ func (s *Service) trackWindow(platformWindow PlatformWindow) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) taskCloseWindow(name string) error {
|
func (s *Service) taskCloseWindow(name string) error {
|
||||||
platformWindow, err := s.requireWindow(name, "window.Service.taskCloseWindow")
|
pw, ok := s.manager.Get(name)
|
||||||
if err != nil {
|
if !ok {
|
||||||
return err
|
return fmt.Errorf("window not found: %s", name)
|
||||||
}
|
}
|
||||||
// Persist state BEFORE closing (spec requirement)
|
// Persist state BEFORE closing (spec requirement)
|
||||||
s.manager.State().CaptureState(platformWindow)
|
s.manager.State().CaptureState(pw)
|
||||||
platformWindow.Close()
|
pw.Close()
|
||||||
s.manager.Remove(name)
|
s.manager.Remove(name)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) taskSetPosition(name string, x, y int) error {
|
func (s *Service) taskSetPosition(name string, x, y int) error {
|
||||||
platformWindow, err := s.requireWindow(name, "window.Service.taskSetPosition")
|
pw, ok := s.manager.Get(name)
|
||||||
if err != nil {
|
if !ok {
|
||||||
return err
|
return fmt.Errorf("window not found: %s", name)
|
||||||
}
|
}
|
||||||
platformWindow.SetPosition(x, y)
|
pw.SetPosition(x, y)
|
||||||
s.manager.State().UpdatePosition(name, x, y)
|
s.manager.State().UpdatePosition(name, x, y)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) taskSetBounds(name string, x, y, width, height int) error {
|
func (s *Service) taskSetSize(name string, width, height, fallbackWidth, fallbackHeight int) error {
|
||||||
platformWindow, err := s.requireWindow(name, "window.Service.taskSetBounds")
|
pw, ok := s.manager.Get(name)
|
||||||
if err != nil {
|
if !ok {
|
||||||
return err
|
return fmt.Errorf("window not found: %s", name)
|
||||||
}
|
}
|
||||||
platformWindow.SetBounds(x, y, width, height)
|
if width == 0 && height == 0 {
|
||||||
s.manager.State().UpdateBounds(name, x, y, width, height)
|
width, height = fallbackWidth, fallbackHeight
|
||||||
return nil
|
} else {
|
||||||
|
if width == 0 {
|
||||||
|
width = fallbackWidth
|
||||||
}
|
}
|
||||||
|
if height == 0 {
|
||||||
func (s *Service) taskSetSize(name string, width, height int) error {
|
height = fallbackHeight
|
||||||
platformWindow, err := s.requireWindow(name, "window.Service.taskSetSize")
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
platformWindow.SetSize(width, height)
|
}
|
||||||
|
pw.SetSize(width, height)
|
||||||
s.manager.State().UpdateSize(name, width, height)
|
s.manager.State().UpdateSize(name, width, height)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) taskMaximize(name string) error {
|
func (s *Service) taskMaximise(name string) error {
|
||||||
platformWindow, err := s.requireWindow(name, "window.Service.taskMaximize")
|
pw, ok := s.manager.Get(name)
|
||||||
if err != nil {
|
if !ok {
|
||||||
return err
|
return fmt.Errorf("window not found: %s", name)
|
||||||
}
|
}
|
||||||
platformWindow.Maximise()
|
pw.Maximise()
|
||||||
s.manager.State().UpdateMaximized(name, true)
|
s.manager.State().UpdateMaximized(name, true)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) taskMinimize(name string) error {
|
func (s *Service) taskMinimise(name string) error {
|
||||||
platformWindow, err := s.requireWindow(name, "window.Service.taskMinimize")
|
pw, ok := s.manager.Get(name)
|
||||||
if err != nil {
|
if !ok {
|
||||||
return err
|
return fmt.Errorf("window not found: %s", name)
|
||||||
}
|
}
|
||||||
platformWindow.Minimise()
|
pw.Minimise()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) taskFocus(name string) error {
|
func (s *Service) taskFocus(name string) error {
|
||||||
platformWindow, err := s.requireWindow(name, "window.Service.taskFocus")
|
pw, ok := s.manager.Get(name)
|
||||||
if err != nil {
|
if !ok {
|
||||||
return err
|
return fmt.Errorf("window not found: %s", name)
|
||||||
}
|
}
|
||||||
platformWindow.Focus()
|
pw.Focus()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) taskRestore(name string) error {
|
func (s *Service) taskRestore(name string) error {
|
||||||
platformWindow, err := s.requireWindow(name, "window.Service.taskRestore")
|
pw, ok := s.manager.Get(name)
|
||||||
if err != nil {
|
if !ok {
|
||||||
return err
|
return fmt.Errorf("window not found: %s", name)
|
||||||
}
|
}
|
||||||
platformWindow.Restore()
|
pw.Restore()
|
||||||
s.manager.State().UpdateMaximized(name, false)
|
s.manager.State().UpdateMaximized(name, false)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) taskSetTitle(name, title string) error {
|
func (s *Service) taskSetTitle(name, title string) error {
|
||||||
platformWindow, err := s.requireWindow(name, "window.Service.taskSetTitle")
|
pw, ok := s.manager.Get(name)
|
||||||
if err != nil {
|
if !ok {
|
||||||
return err
|
return fmt.Errorf("window not found: %s", name)
|
||||||
}
|
}
|
||||||
platformWindow.SetTitle(title)
|
pw.SetTitle(title)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) taskSetAlwaysOnTop(name string, alwaysOnTop bool) error {
|
func (s *Service) taskSetAlwaysOnTop(name string, alwaysOnTop bool) error {
|
||||||
platformWindow, err := s.requireWindow(name, "window.Service.taskSetAlwaysOnTop")
|
pw, ok := s.manager.Get(name)
|
||||||
if err != nil {
|
if !ok {
|
||||||
return err
|
return fmt.Errorf("window not found: %s", name)
|
||||||
}
|
}
|
||||||
platformWindow.SetAlwaysOnTop(alwaysOnTop)
|
pw.SetAlwaysOnTop(alwaysOnTop)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) taskSetBackgroundColour(name string, red, green, blue, alpha uint8) error {
|
func (s *Service) taskSetBackgroundColour(name string, red, green, blue, alpha uint8) error {
|
||||||
platformWindow, err := s.requireWindow(name, "window.Service.taskSetBackgroundColour")
|
pw, ok := s.manager.Get(name)
|
||||||
if err != nil {
|
if !ok {
|
||||||
return err
|
return fmt.Errorf("window not found: %s", name)
|
||||||
}
|
}
|
||||||
platformWindow.SetBackgroundColour(red, green, blue, alpha)
|
pw.SetBackgroundColour(red, green, blue, alpha)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) taskSetOpacity(name string, opacity float32) error {
|
||||||
|
if opacity < 0 || opacity > 1 {
|
||||||
|
return fmt.Errorf("opacity must be between 0 and 1")
|
||||||
|
}
|
||||||
|
pw, ok := s.manager.Get(name)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("window not found: %s", name)
|
||||||
|
}
|
||||||
|
pw.SetOpacity(opacity)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) taskSetVisibility(name string, visible bool) error {
|
func (s *Service) taskSetVisibility(name string, visible bool) error {
|
||||||
platformWindow, err := s.requireWindow(name, "window.Service.taskSetVisibility")
|
pw, ok := s.manager.Get(name)
|
||||||
if err != nil {
|
if !ok {
|
||||||
return err
|
return fmt.Errorf("window not found: %s", name)
|
||||||
}
|
}
|
||||||
platformWindow.SetVisibility(visible)
|
pw.SetVisibility(visible)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) taskFullscreen(name string, fullscreen bool) error {
|
func (s *Service) taskFullscreen(name string, fullscreen bool) error {
|
||||||
platformWindow, err := s.requireWindow(name, "window.Service.taskFullscreen")
|
pw, ok := s.manager.Get(name)
|
||||||
if err != nil {
|
if !ok {
|
||||||
return err
|
return fmt.Errorf("window not found: %s", name)
|
||||||
}
|
}
|
||||||
if fullscreen {
|
if fullscreen {
|
||||||
platformWindow.Fullscreen()
|
pw.Fullscreen()
|
||||||
} else {
|
} else {
|
||||||
platformWindow.UnFullscreen()
|
pw.UnFullscreen()
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -431,20 +433,23 @@ func (s *Service) taskSaveLayout(name string) error {
|
||||||
func (s *Service) taskRestoreLayout(name string) error {
|
func (s *Service) taskRestoreLayout(name string) error {
|
||||||
layout, ok := s.manager.Layout().GetLayout(name)
|
layout, ok := s.manager.Layout().GetLayout(name)
|
||||||
if !ok {
|
if !ok {
|
||||||
return coreerr.E("window.Service.taskRestoreLayout", "layout not found: "+name, nil)
|
return fmt.Errorf("layout not found: %s", name)
|
||||||
}
|
}
|
||||||
for winName, state := range layout.Windows {
|
for winName, state := range layout.Windows {
|
||||||
platformWindow, found := s.manager.Get(winName)
|
pw, found := s.manager.Get(winName)
|
||||||
if !found {
|
if !found {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
platformWindow.SetBounds(state.X, state.Y, state.Width, state.Height)
|
if pw.IsMaximised() || pw.IsMinimised() {
|
||||||
if state.Maximized {
|
pw.Restore()
|
||||||
platformWindow.Maximise()
|
}
|
||||||
} else {
|
pw.SetPosition(state.X, state.Y)
|
||||||
platformWindow.Restore()
|
pw.SetSize(state.Width, state.Height)
|
||||||
|
if state.Maximized {
|
||||||
|
pw.Maximise()
|
||||||
|
} else {
|
||||||
|
pw.Restore()
|
||||||
}
|
}
|
||||||
s.manager.State().CaptureState(platformWindow)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -460,21 +465,13 @@ var tileModeMap = map[string]TileMode{
|
||||||
func (s *Service) taskTileWindows(mode string, names []string) error {
|
func (s *Service) taskTileWindows(mode string, names []string) error {
|
||||||
tm, ok := tileModeMap[mode]
|
tm, ok := tileModeMap[mode]
|
||||||
if !ok {
|
if !ok {
|
||||||
return coreerr.E("window.Service.taskTileWindows", "unknown tile mode: "+mode, nil)
|
return fmt.Errorf("unknown tile mode: %s", mode)
|
||||||
}
|
}
|
||||||
if len(names) == 0 {
|
if len(names) == 0 {
|
||||||
names = s.manager.List()
|
names = s.manager.List()
|
||||||
}
|
}
|
||||||
originX, originY, screenWidth, screenHeight := s.primaryScreenArea()
|
screenW, screenH := s.primaryScreenSize()
|
||||||
return s.manager.TileWindows(tm, names, screenWidth, screenHeight, originX, originY)
|
return s.manager.TileWindows(tm, names, screenW, screenH)
|
||||||
}
|
|
||||||
|
|
||||||
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{
|
var snapPosMap = map[string]SnapPosition{
|
||||||
|
|
@ -482,38 +479,110 @@ var snapPosMap = map[string]SnapPosition{
|
||||||
"top": SnapTop, "bottom": SnapBottom,
|
"top": SnapTop, "bottom": SnapBottom,
|
||||||
"top-left": SnapTopLeft, "top-right": SnapTopRight,
|
"top-left": SnapTopLeft, "top-right": SnapTopRight,
|
||||||
"bottom-left": SnapBottomLeft, "bottom-right": SnapBottomRight,
|
"bottom-left": SnapBottomLeft, "bottom-right": SnapBottomRight,
|
||||||
"center": SnapCenter,
|
"center": SnapCenter, "centre": SnapCenter,
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) taskSnapWindow(name, position string) error {
|
func (s *Service) taskSnapWindow(name, position string) error {
|
||||||
pos, ok := snapPosMap[position]
|
pos, ok := snapPosMap[position]
|
||||||
if !ok {
|
if !ok {
|
||||||
return coreerr.E("window.Service.taskSnapWindow", "unknown snap position: "+position, nil)
|
return fmt.Errorf("unknown snap position: %s", position)
|
||||||
}
|
}
|
||||||
originX, originY, screenWidth, screenHeight := s.primaryScreenArea()
|
screenW, screenH := s.primaryScreenSize()
|
||||||
return s.manager.SnapWindow(name, pos, screenWidth, screenHeight, originX, originY)
|
return s.manager.SnapWindow(name, pos, screenW, screenH)
|
||||||
}
|
}
|
||||||
|
|
||||||
var workflowLayoutMap = map[string]WorkflowLayout{
|
func (s *Service) taskArrangePair(first, second string) error {
|
||||||
"coding": WorkflowCoding,
|
screenW, screenH := s.primaryScreenSize()
|
||||||
"debugging": WorkflowDebugging,
|
return s.manager.ArrangePair(first, second, screenW, screenH)
|
||||||
"presenting": WorkflowPresenting,
|
|
||||||
"side-by-side": WorkflowSideBySide,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) taskApplyWorkflow(workflow string, names []string) error {
|
func (s *Service) taskBesideEditor(editorName, windowName string) error {
|
||||||
layout, ok := workflowLayoutMap[workflow]
|
screenW, screenH := s.primaryScreenSize()
|
||||||
if !ok {
|
if editorName == "" {
|
||||||
return coreerr.E("window.Service.taskApplyWorkflow", "unknown workflow layout: "+workflow, nil)
|
editorName = s.detectEditorWindow()
|
||||||
}
|
}
|
||||||
|
if editorName == "" {
|
||||||
|
return fmt.Errorf("editor window not found")
|
||||||
|
}
|
||||||
|
if windowName == "" {
|
||||||
|
windowName = s.detectCompanionWindow(editorName)
|
||||||
|
}
|
||||||
|
if windowName == "" {
|
||||||
|
return fmt.Errorf("companion window not found")
|
||||||
|
}
|
||||||
|
return s.manager.BesideEditor(editorName, windowName, screenW, screenH)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) taskStackWindows(names []string, offsetX, offsetY int) error {
|
||||||
if len(names) == 0 {
|
if len(names) == 0 {
|
||||||
names = s.manager.List()
|
names = s.manager.List()
|
||||||
}
|
}
|
||||||
originX, originY, screenWidth, screenHeight := s.primaryScreenArea()
|
return s.manager.StackWindows(names, offsetX, offsetY)
|
||||||
return s.manager.ApplyWorkflow(layout, names, screenWidth, screenHeight, originX, originY)
|
}
|
||||||
|
|
||||||
|
func (s *Service) taskApplyWorkflow(workflow WorkflowLayout, names []string) error {
|
||||||
|
screenW, screenH := s.primaryScreenSize()
|
||||||
|
if len(names) == 0 {
|
||||||
|
names = s.manager.List()
|
||||||
|
}
|
||||||
|
return s.manager.ApplyWorkflow(workflow, names, screenW, screenH)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) detectEditorWindow() string {
|
||||||
|
for _, info := range s.queryWindowList() {
|
||||||
|
if looksLikeEditor(info.Name, info.Title) {
|
||||||
|
return info.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) detectCompanionWindow(editorName string) string {
|
||||||
|
for _, info := range s.queryWindowList() {
|
||||||
|
if info.Name == editorName {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !looksLikeEditor(info.Name, info.Title) {
|
||||||
|
return info.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksLikeEditor(name, title string) bool {
|
||||||
|
return containsAny(name, "editor", "ide", "code", "workspace") || containsAny(title, "editor", "ide", "code")
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsAny(value string, needles ...string) bool {
|
||||||
|
lower := strings.ToLower(value)
|
||||||
|
for _, needle := range needles {
|
||||||
|
if strings.Contains(lower, needle) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) primaryScreenSize() (int, int) {
|
||||||
|
result, handled, err := s.Core().QUERY(screen.QueryPrimary{})
|
||||||
|
if err == nil && handled {
|
||||||
|
if scr, ok := result.(*screen.Screen); ok && scr != nil {
|
||||||
|
if scr.WorkArea.Width > 0 && scr.WorkArea.Height > 0 {
|
||||||
|
return scr.WorkArea.Width, scr.WorkArea.Height
|
||||||
|
}
|
||||||
|
if scr.Bounds.Width > 0 && scr.Bounds.Height > 0 {
|
||||||
|
return scr.Bounds.Width, scr.Bounds.Height
|
||||||
|
}
|
||||||
|
if scr.Size.Width > 0 && scr.Size.Height > 0 {
|
||||||
|
return scr.Size.Width, scr.Size.Height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 1920, 1080
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manager returns the underlying window Manager for direct access.
|
// Manager returns the underlying window Manager for direct access.
|
||||||
|
// Use: mgr := svc.Manager()
|
||||||
func (s *Service) Manager() *Manager {
|
func (s *Service) Manager() *Manager {
|
||||||
return s.manager
|
return s.manager
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,177 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
@ -6,14 +6,13 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"forge.lthn.ai/core/go/pkg/core"
|
"forge.lthn.ai/core/go/pkg/core"
|
||||||
|
"forge.lthn.ai/core/gui/pkg/screen"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newTestWindowService(t *testing.T) (*Service, *core.Core) {
|
func newTestWindowService(t *testing.T) (*Service, *core.Core) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
|
||||||
|
|
||||||
c, err := core.New(
|
c, err := core.New(
|
||||||
core.WithService(Register(newMockPlatform())),
|
core.WithService(Register(newMockPlatform())),
|
||||||
core.WithServiceLock(),
|
core.WithServiceLock(),
|
||||||
|
|
@ -24,34 +23,83 @@ func newTestWindowService(t *testing.T) (*Service, *core.Core) {
|
||||||
return svc, c
|
return svc, c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type testScreenPlatform struct {
|
||||||
|
screens []screen.Screen
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *testScreenPlatform) GetAll() []screen.Screen { return p.screens }
|
||||||
|
|
||||||
|
func (p *testScreenPlatform) GetPrimary() *screen.Screen {
|
||||||
|
for i := range p.screens {
|
||||||
|
if p.screens[i].IsPrimary {
|
||||||
|
return &p.screens[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestWindowServiceWithScreen(t *testing.T) (*Service, *core.Core) {
|
||||||
|
t.Helper()
|
||||||
|
c, err := core.New(
|
||||||
|
core.WithService(Register(newMockPlatform())),
|
||||||
|
core.WithService(screen.Register(&testScreenPlatform{
|
||||||
|
screens: []screen.Screen{{
|
||||||
|
ID: "primary", Name: "Primary", IsPrimary: true,
|
||||||
|
Size: screen.Size{Width: 2560, Height: 1440},
|
||||||
|
Bounds: screen.Rect{X: 0, Y: 0, Width: 2560, Height: 1440},
|
||||||
|
WorkArea: screen.Rect{X: 0, Y: 0, Width: 2560, Height: 1440},
|
||||||
|
}},
|
||||||
|
})),
|
||||||
|
core.WithServiceLock(),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
||||||
|
svc := core.MustServiceFor[*Service](c, "window")
|
||||||
|
return svc, c
|
||||||
|
}
|
||||||
|
|
||||||
func TestRegister_Good(t *testing.T) {
|
func TestRegister_Good(t *testing.T) {
|
||||||
svc, _ := newTestWindowService(t)
|
svc, _ := newTestWindowService(t)
|
||||||
assert.NotNil(t, svc)
|
assert.NotNil(t, svc)
|
||||||
assert.NotNil(t, svc.manager)
|
assert.NotNil(t, svc.manager)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestApplyConfig_Good(t *testing.T) {
|
||||||
|
svc, _ := newTestWindowService(t)
|
||||||
|
|
||||||
|
svc.applyConfig(map[string]any{
|
||||||
|
"default_width": 1500,
|
||||||
|
"default_height": 900,
|
||||||
|
})
|
||||||
|
|
||||||
|
pw, err := svc.manager.Open()
|
||||||
|
require.NoError(t, err)
|
||||||
|
w, h := pw.Size()
|
||||||
|
assert.Equal(t, 1500, w)
|
||||||
|
assert.Equal(t, 900, h)
|
||||||
|
}
|
||||||
|
|
||||||
func TestTaskOpenWindow_Good(t *testing.T) {
|
func TestTaskOpenWindow_Good(t *testing.T) {
|
||||||
_, c := newTestWindowService(t)
|
_, c := newTestWindowService(t)
|
||||||
result, handled, err := c.PERFORM(TaskOpenWindow{
|
result, handled, err := c.PERFORM(TaskOpenWindow{
|
||||||
Window: Window{Name: "test", URL: "/"},
|
Opts: []WindowOption{WithName("test"), WithURL("/")},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, handled)
|
assert.True(t, handled)
|
||||||
info := result.(WindowInfo)
|
info := result.(WindowInfo)
|
||||||
assert.Equal(t, "test", info.Name)
|
assert.Equal(t, "test", info.Name)
|
||||||
assert.True(t, info.Visible)
|
|
||||||
assert.False(t, info.Minimized)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskOpenWindow_Declarative_Good(t *testing.T) {
|
func TestTaskOpenWindowDescriptor_Good(t *testing.T) {
|
||||||
_, c := newTestWindowService(t)
|
_, c := newTestWindowService(t)
|
||||||
result, handled, err := c.PERFORM(TaskOpenWindow{
|
result, handled, err := c.PERFORM(TaskOpenWindow{
|
||||||
Window: Window{Name: "test-fallback", URL: "/"},
|
Window: &Window{Name: "descriptor", Title: "Descriptor", Width: 640, Height: 480},
|
||||||
})
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, handled)
|
assert.True(t, handled)
|
||||||
info := result.(WindowInfo)
|
info := result.(WindowInfo)
|
||||||
assert.Equal(t, "test-fallback", info.Name)
|
assert.Equal(t, "descriptor", info.Name)
|
||||||
|
assert.Equal(t, "Descriptor", info.Title)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskOpenWindow_Bad(t *testing.T) {
|
func TestTaskOpenWindow_Bad(t *testing.T) {
|
||||||
|
|
@ -64,25 +112,38 @@ func TestTaskOpenWindow_Bad(t *testing.T) {
|
||||||
|
|
||||||
func TestQueryWindowList_Good(t *testing.T) {
|
func TestQueryWindowList_Good(t *testing.T) {
|
||||||
_, c := newTestWindowService(t)
|
_, c := newTestWindowService(t)
|
||||||
_, _, _ = c.PERFORM(TaskOpenWindow{Window: Window{Name: "a"}})
|
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("a")}})
|
||||||
_, _, _ = c.PERFORM(TaskOpenWindow{Window: Window{Name: "b"}})
|
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("b")}})
|
||||||
|
_, _, _ = c.PERFORM(TaskMinimise{Name: "b"})
|
||||||
|
|
||||||
result, handled, err := c.QUERY(QueryWindowList{})
|
result, handled, err := c.QUERY(QueryWindowList{})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, handled)
|
assert.True(t, handled)
|
||||||
list := result.([]WindowInfo)
|
list := result.([]WindowInfo)
|
||||||
assert.Len(t, list, 2)
|
assert.Len(t, list, 2)
|
||||||
|
|
||||||
|
byName := make(map[string]WindowInfo, len(list))
|
||||||
|
for _, info := range list {
|
||||||
|
byName[info.Name] = info
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.True(t, byName["a"].Visible)
|
||||||
|
assert.False(t, byName["a"].Minimized)
|
||||||
|
assert.False(t, byName["b"].Visible)
|
||||||
|
assert.True(t, byName["b"].Minimized)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryWindowByName_Good(t *testing.T) {
|
func TestQueryWindowByName_Good(t *testing.T) {
|
||||||
_, c := newTestWindowService(t)
|
_, c := newTestWindowService(t)
|
||||||
_, _, _ = c.PERFORM(TaskOpenWindow{Window: Window{Name: "test"}})
|
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
|
||||||
|
|
||||||
result, handled, err := c.QUERY(QueryWindowByName{Name: "test"})
|
result, handled, err := c.QUERY(QueryWindowByName{Name: "test"})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, handled)
|
assert.True(t, handled)
|
||||||
info := result.(*WindowInfo)
|
info := result.(*WindowInfo)
|
||||||
assert.Equal(t, "test", info.Name)
|
assert.Equal(t, "test", info.Name)
|
||||||
|
assert.True(t, info.Visible)
|
||||||
|
assert.False(t, info.Minimized)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestQueryWindowByName_Bad(t *testing.T) {
|
func TestQueryWindowByName_Bad(t *testing.T) {
|
||||||
|
|
@ -93,37 +154,13 @@ func TestQueryWindowByName_Bad(t *testing.T) {
|
||||||
assert.Nil(t, result)
|
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) {
|
func TestTaskCloseWindow_Good(t *testing.T) {
|
||||||
_, c := newTestWindowService(t)
|
_, c := newTestWindowService(t)
|
||||||
_, _, _ = c.PERFORM(TaskOpenWindow{Window: Window{Name: "test"}})
|
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("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"})
|
_, handled, err := c.PERFORM(TaskCloseWindow{Name: "test"})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, handled)
|
assert.True(t, handled)
|
||||||
assert.Equal(t, 1, closedCount)
|
|
||||||
|
|
||||||
// Verify window is removed
|
// Verify window is removed
|
||||||
result, _, _ := c.QUERY(QueryWindowByName{Name: "test"})
|
result, _, _ := c.QUERY(QueryWindowByName{Name: "test"})
|
||||||
|
|
@ -137,23 +174,9 @@ func TestTaskCloseWindow_Bad(t *testing.T) {
|
||||||
assert.Error(t, err)
|
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) {
|
func TestTaskSetPosition_Good(t *testing.T) {
|
||||||
_, c := newTestWindowService(t)
|
_, c := newTestWindowService(t)
|
||||||
_, _, _ = c.PERFORM(TaskOpenWindow{Window: Window{Name: "test"}})
|
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
|
||||||
|
|
||||||
_, handled, err := c.PERFORM(TaskSetPosition{Name: "test", X: 100, Y: 200})
|
_, handled, err := c.PERFORM(TaskSetPosition{Name: "test", X: 100, Y: 200})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
@ -167,9 +190,9 @@ func TestTaskSetPosition_Good(t *testing.T) {
|
||||||
|
|
||||||
func TestTaskSetSize_Good(t *testing.T) {
|
func TestTaskSetSize_Good(t *testing.T) {
|
||||||
_, c := newTestWindowService(t)
|
_, c := newTestWindowService(t)
|
||||||
_, _, _ = c.PERFORM(TaskOpenWindow{Window: Window{Name: "test"}})
|
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
|
||||||
|
|
||||||
_, handled, err := c.PERFORM(TaskSetSize{Name: "test", Width: 800, Height: 600})
|
_, handled, err := c.PERFORM(TaskSetSize{Name: "test", W: 800, H: 600})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, handled)
|
assert.True(t, handled)
|
||||||
|
|
||||||
|
|
@ -179,47 +202,183 @@ func TestTaskSetSize_Good(t *testing.T) {
|
||||||
assert.Equal(t, 600, info.Height)
|
assert.Equal(t, 600, info.Height)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskSetBounds_Good(t *testing.T) {
|
func TestTaskMinimiseAndVisibility_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(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 := newTestWindowService(t)
|
||||||
_, _, _ = c.PERFORM(TaskOpenWindow{Window: Window{Name: "test"}})
|
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
|
||||||
|
|
||||||
_, handled, err := c.PERFORM(TaskMaximize{Name: "test"})
|
_, handled, err := c.PERFORM(TaskMinimise{Name: "test"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
|
||||||
|
result, _, _ := c.QUERY(QueryWindowByName{Name: "test"})
|
||||||
|
info := result.(*WindowInfo)
|
||||||
|
assert.True(t, info.Minimized)
|
||||||
|
assert.False(t, info.Visible)
|
||||||
|
|
||||||
|
_, handled, err = c.PERFORM(TaskSetVisibility{Name: "test", Visible: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
|
||||||
|
result, _, _ = c.QUERY(QueryWindowByName{Name: "test"})
|
||||||
|
info = result.(*WindowInfo)
|
||||||
|
assert.True(t, info.Visible)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskSetAlwaysOnTop_Good(t *testing.T) {
|
||||||
|
_, c := newTestWindowService(t)
|
||||||
|
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
|
||||||
|
|
||||||
|
_, handled, err := c.PERFORM(TaskSetAlwaysOnTop{Name: "test", AlwaysOnTop: true})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
|
||||||
|
svc := core.MustServiceFor[*Service](c, "window")
|
||||||
|
pw, ok := svc.Manager().Get("test")
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.True(t, pw.(*mockWindow).alwaysOnTop)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskSetBackgroundColour_Good(t *testing.T) {
|
||||||
|
_, c := newTestWindowService(t)
|
||||||
|
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
|
||||||
|
|
||||||
|
_, handled, err := c.PERFORM(TaskSetBackgroundColour{
|
||||||
|
Name: "test", Red: 10, Green: 20, Blue: 30, Alpha: 40,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
|
||||||
|
svc := core.MustServiceFor[*Service](c, "window")
|
||||||
|
pw, ok := svc.Manager().Get("test")
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, [4]uint8{10, 20, 30, 40}, pw.(*mockWindow).backgroundColor)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskTileWindows_UsesPrimaryScreenSize(t *testing.T) {
|
||||||
|
_, c := newTestWindowServiceWithScreen(t)
|
||||||
|
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("left")}})
|
||||||
|
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("right")}})
|
||||||
|
|
||||||
|
_, handled, err := c.PERFORM(TaskTileWindows{Mode: "left-right", Windows: []string{"left", "right"}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
|
||||||
|
left, _, _ := c.QUERY(QueryWindowByName{Name: "left"})
|
||||||
|
right, _, _ := c.QUERY(QueryWindowByName{Name: "right"})
|
||||||
|
leftInfo := left.(*WindowInfo)
|
||||||
|
rightInfo := right.(*WindowInfo)
|
||||||
|
|
||||||
|
assert.Equal(t, 1280, leftInfo.Width)
|
||||||
|
assert.Equal(t, 1280, rightInfo.Width)
|
||||||
|
assert.Equal(t, 0, leftInfo.X)
|
||||||
|
assert.Equal(t, 1280, rightInfo.X)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskTileWindows_ResetsMaximizedState(t *testing.T) {
|
||||||
|
_, c := newTestWindowServiceWithScreen(t)
|
||||||
|
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("left")}})
|
||||||
|
|
||||||
|
_, _, _ = c.PERFORM(TaskMaximise{Name: "left"})
|
||||||
|
_, handled, err := c.PERFORM(TaskTileWindows{Mode: "left-half", Windows: []string{"left"}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
|
||||||
|
result, _, _ := c.QUERY(QueryWindowByName{Name: "left"})
|
||||||
|
info := result.(*WindowInfo)
|
||||||
|
assert.False(t, info.Maximized)
|
||||||
|
assert.Equal(t, 0, info.X)
|
||||||
|
assert.Equal(t, 1280, info.Width)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskSetOpacity_Good(t *testing.T) {
|
||||||
|
_, c := newTestWindowService(t)
|
||||||
|
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
|
||||||
|
|
||||||
|
_, handled, err := c.PERFORM(TaskSetOpacity{Name: "test", Opacity: 0.65})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
|
||||||
|
svc := core.MustServiceFor[*Service](c, "window")
|
||||||
|
pw, ok := svc.Manager().Get("test")
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.InDelta(t, 0.65, pw.(*mockWindow).opacity, 0.0001)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskSetOpacity_BadRange(t *testing.T) {
|
||||||
|
_, c := newTestWindowService(t)
|
||||||
|
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
|
||||||
|
|
||||||
|
_, handled, err := c.PERFORM(TaskSetOpacity{Name: "test", Opacity: 1.5})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskStackWindows_Good(t *testing.T) {
|
||||||
|
_, c := newTestWindowService(t)
|
||||||
|
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("one")}})
|
||||||
|
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("two")}})
|
||||||
|
|
||||||
|
_, handled, err := c.PERFORM(TaskStackWindows{
|
||||||
|
Windows: []string{"one", "two"},
|
||||||
|
OffsetX: 20,
|
||||||
|
OffsetY: 30,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
|
||||||
|
result, _, _ := c.QUERY(QueryWindowByName{Name: "two"})
|
||||||
|
info := result.(*WindowInfo)
|
||||||
|
assert.Equal(t, 20, info.X)
|
||||||
|
assert.Equal(t, 30, info.Y)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskApplyWorkflow_Good(t *testing.T) {
|
||||||
|
_, c := newTestWindowService(t)
|
||||||
|
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("editor")}})
|
||||||
|
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("assistant")}})
|
||||||
|
|
||||||
|
_, handled, err := c.PERFORM(TaskApplyWorkflow{
|
||||||
|
Workflow: WorkflowCoding,
|
||||||
|
Windows: []string{"editor", "assistant"},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
|
||||||
|
editorResult, _, _ := c.QUERY(QueryWindowByName{Name: "editor"})
|
||||||
|
assistantResult, _, _ := c.QUERY(QueryWindowByName{Name: "assistant"})
|
||||||
|
editor := editorResult.(*WindowInfo)
|
||||||
|
assistant := assistantResult.(*WindowInfo)
|
||||||
|
assert.Greater(t, editor.Width, assistant.Width)
|
||||||
|
assert.Equal(t, editor.Width, assistant.X)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskRestoreLayout_ClearsMaximizedState(t *testing.T) {
|
||||||
|
_, c := newTestWindowServiceWithScreen(t)
|
||||||
|
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("editor")}})
|
||||||
|
_, _, _ = c.PERFORM(TaskMaximise{Name: "editor"})
|
||||||
|
|
||||||
|
svc := core.MustServiceFor[*Service](c, "window")
|
||||||
|
err := svc.Manager().Layout().SaveLayout("restore", map[string]WindowState{
|
||||||
|
"editor": {X: 12, Y: 34, Width: 640, Height: 480, Maximized: false},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, handled, err := c.PERFORM(TaskRestoreLayout{Name: "restore"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, handled)
|
||||||
|
|
||||||
|
result, _, _ := c.QUERY(QueryWindowByName{Name: "editor"})
|
||||||
|
info := result.(*WindowInfo)
|
||||||
|
assert.False(t, info.Maximized)
|
||||||
|
assert.Equal(t, 12, info.X)
|
||||||
|
assert.Equal(t, 640, info.Width)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTaskMaximise_Good(t *testing.T) {
|
||||||
|
_, c := newTestWindowService(t)
|
||||||
|
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
|
||||||
|
|
||||||
|
_, handled, err := c.PERFORM(TaskMaximise{Name: "test"})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, handled)
|
assert.True(t, handled)
|
||||||
|
|
||||||
|
|
@ -233,7 +392,7 @@ func TestFileDrop_Good(t *testing.T) {
|
||||||
|
|
||||||
// Open a window
|
// Open a window
|
||||||
result, _, _ := c.PERFORM(TaskOpenWindow{
|
result, _, _ := c.PERFORM(TaskOpenWindow{
|
||||||
Window: Window{Name: "drop-test"},
|
Opts: []WindowOption{WithName("drop-test")},
|
||||||
})
|
})
|
||||||
info := result.(WindowInfo)
|
info := result.(WindowInfo)
|
||||||
assert.Equal(t, "drop-test", info.Name)
|
assert.Equal(t, "drop-test", info.Name)
|
||||||
|
|
@ -263,399 +422,3 @@ func TestFileDrop_Good(t *testing.T) {
|
||||||
assert.Equal(t, "upload-zone", dropped.TargetID)
|
assert.Equal(t, "upload-zone", dropped.TargetID)
|
||||||
mu.Unlock()
|
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)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,13 @@ import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
coreio "forge.lthn.ai/core/go-io"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// WindowState holds the persisted position/size of a window.
|
// WindowState holds the persisted position/size of a window.
|
||||||
// JSON tags match the existing window_state.json format.
|
// JSON tags match existing window_state.json format for backward compat.
|
||||||
|
// Use: state := window.WindowState{X: 10, Y: 20, Width: 1280, Height: 800}
|
||||||
type WindowState struct {
|
type WindowState struct {
|
||||||
X int `json:"x,omitempty"`
|
X int `json:"x,omitempty"`
|
||||||
Y int `json:"y,omitempty"`
|
Y int `json:"y,omitempty"`
|
||||||
|
|
@ -26,6 +24,7 @@ type WindowState struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// StateManager persists window positions to ~/.config/Core/window_state.json.
|
// StateManager persists window positions to ~/.config/Core/window_state.json.
|
||||||
|
// Use: sm := window.NewStateManager()
|
||||||
type StateManager struct {
|
type StateManager struct {
|
||||||
configDir string
|
configDir string
|
||||||
statePath string
|
statePath string
|
||||||
|
|
@ -35,6 +34,7 @@ type StateManager struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewStateManager creates a StateManager loading from the default config directory.
|
// NewStateManager creates a StateManager loading from the default config directory.
|
||||||
|
// Use: sm := window.NewStateManager()
|
||||||
func NewStateManager() *StateManager {
|
func NewStateManager() *StateManager {
|
||||||
sm := &StateManager{
|
sm := &StateManager{
|
||||||
states: make(map[string]WindowState),
|
states: make(map[string]WindowState),
|
||||||
|
|
@ -49,6 +49,7 @@ func NewStateManager() *StateManager {
|
||||||
|
|
||||||
// NewStateManagerWithDir creates a StateManager loading from a custom config directory.
|
// NewStateManagerWithDir creates a StateManager loading from a custom config directory.
|
||||||
// Useful for testing or when the default config directory is not appropriate.
|
// Useful for testing or when the default config directory is not appropriate.
|
||||||
|
// Use: sm := window.NewStateManagerWithDir(t.TempDir())
|
||||||
func NewStateManagerWithDir(configDir string) *StateManager {
|
func NewStateManagerWithDir(configDir string) *StateManager {
|
||||||
sm := &StateManager{
|
sm := &StateManager{
|
||||||
configDir: configDir,
|
configDir: configDir,
|
||||||
|
|
@ -72,12 +73,17 @@ func (sm *StateManager) dataDir() string {
|
||||||
return sm.configDir
|
return sm.configDir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetPath overrides the persisted state file path.
|
||||||
|
// Use: sm.SetPath(filepath.Join(t.TempDir(), "window_state.json"))
|
||||||
func (sm *StateManager) SetPath(path string) {
|
func (sm *StateManager) SetPath(path string) {
|
||||||
if path == "" {
|
if path == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sm.mu.Lock()
|
sm.mu.Lock()
|
||||||
sm.stopSaveTimerLocked()
|
if sm.saveTimer != nil {
|
||||||
|
sm.saveTimer.Stop()
|
||||||
|
sm.saveTimer = nil
|
||||||
|
}
|
||||||
sm.statePath = path
|
sm.statePath = path
|
||||||
sm.states = make(map[string]WindowState)
|
sm.states = make(map[string]WindowState)
|
||||||
sm.mu.Unlock()
|
sm.mu.Unlock()
|
||||||
|
|
@ -88,13 +94,13 @@ func (sm *StateManager) load() {
|
||||||
if sm.configDir == "" && sm.statePath == "" {
|
if sm.configDir == "" && sm.statePath == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
content, err := coreio.Local.Read(sm.filePath())
|
data, err := os.ReadFile(sm.filePath())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sm.mu.Lock()
|
sm.mu.Lock()
|
||||||
defer sm.mu.Unlock()
|
defer sm.mu.Unlock()
|
||||||
_ = json.Unmarshal([]byte(content), &sm.states)
|
_ = json.Unmarshal(data, &sm.states)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sm *StateManager) save() {
|
func (sm *StateManager) save() {
|
||||||
|
|
@ -107,37 +113,19 @@ func (sm *StateManager) save() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if dir := sm.dataDir(); dir != "" {
|
_ = os.MkdirAll(sm.dataDir(), 0o755)
|
||||||
_ = coreio.Local.EnsureDir(dir)
|
_ = os.WriteFile(sm.filePath(), data, 0o644)
|
||||||
}
|
|
||||||
_ = coreio.Local.Write(sm.filePath(), string(data))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (sm *StateManager) scheduleSave() {
|
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 {
|
if sm.saveTimer != nil {
|
||||||
sm.saveTimer.Stop()
|
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.
|
// GetState returns the saved state for a window name.
|
||||||
|
// Use: state, ok := sm.GetState("editor")
|
||||||
func (sm *StateManager) GetState(name string) (WindowState, bool) {
|
func (sm *StateManager) GetState(name string) (WindowState, bool) {
|
||||||
sm.mu.RLock()
|
sm.mu.RLock()
|
||||||
defer sm.mu.RUnlock()
|
defer sm.mu.RUnlock()
|
||||||
|
|
@ -146,63 +134,66 @@ func (sm *StateManager) GetState(name string) (WindowState, bool) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetState saves state for a window name (debounced disk write).
|
// SetState saves state for a window name (debounced disk write).
|
||||||
|
// Use: sm.SetState("editor", window.WindowState{Width: 1280, Height: 800})
|
||||||
func (sm *StateManager) SetState(name string, state WindowState) {
|
func (sm *StateManager) SetState(name string, state WindowState) {
|
||||||
sm.updateState(name, func(current *WindowState) {
|
state.UpdatedAt = time.Now().UnixMilli()
|
||||||
*current = state
|
sm.mu.Lock()
|
||||||
})
|
sm.states[name] = state
|
||||||
|
sm.mu.Unlock()
|
||||||
|
sm.scheduleSave()
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdatePosition updates only the position fields.
|
// UpdatePosition updates only the position fields.
|
||||||
|
// Use: sm.UpdatePosition("editor", 160, 120)
|
||||||
func (sm *StateManager) UpdatePosition(name string, x, y int) {
|
func (sm *StateManager) UpdatePosition(name string, x, y int) {
|
||||||
sm.updateState(name, func(state *WindowState) {
|
sm.mu.Lock()
|
||||||
state.X = x
|
s := sm.states[name]
|
||||||
state.Y = y
|
s.X = x
|
||||||
})
|
s.Y = y
|
||||||
|
s.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
sm.states[name] = s
|
||||||
|
sm.mu.Unlock()
|
||||||
|
sm.scheduleSave()
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSize updates only the size fields.
|
// UpdateSize updates only the size fields.
|
||||||
|
// Use: sm.UpdateSize("editor", 1280, 800)
|
||||||
func (sm *StateManager) UpdateSize(name string, width, height int) {
|
func (sm *StateManager) UpdateSize(name string, width, height int) {
|
||||||
sm.updateState(name, func(state *WindowState) {
|
sm.mu.Lock()
|
||||||
state.Width = width
|
s := sm.states[name]
|
||||||
state.Height = height
|
s.Width = width
|
||||||
})
|
s.Height = height
|
||||||
}
|
s.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
sm.states[name] = s
|
||||||
// UpdateBounds updates position and size in one state write.
|
sm.mu.Unlock()
|
||||||
func (sm *StateManager) UpdateBounds(name string, x, y, width, height int) {
|
sm.scheduleSave()
|
||||||
sm.updateState(name, func(state *WindowState) {
|
|
||||||
state.X = x
|
|
||||||
state.Y = y
|
|
||||||
state.Width = width
|
|
||||||
state.Height = height
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateMaximized updates the maximized flag.
|
// UpdateMaximized updates the maximized flag.
|
||||||
|
// Use: sm.UpdateMaximized("editor", true)
|
||||||
func (sm *StateManager) UpdateMaximized(name string, maximized bool) {
|
func (sm *StateManager) UpdateMaximized(name string, maximized bool) {
|
||||||
sm.updateState(name, func(state *WindowState) {
|
sm.mu.Lock()
|
||||||
state.Maximized = maximized
|
s := sm.states[name]
|
||||||
})
|
s.Maximized = maximized
|
||||||
|
s.UpdatedAt = time.Now().UnixMilli()
|
||||||
|
sm.states[name] = s
|
||||||
|
sm.mu.Unlock()
|
||||||
|
sm.scheduleSave()
|
||||||
}
|
}
|
||||||
|
|
||||||
// CaptureState snapshots the current state from a PlatformWindow.
|
// CaptureState snapshots the current state from a PlatformWindow.
|
||||||
|
// Use: sm.CaptureState(pw)
|
||||||
func (sm *StateManager) CaptureState(pw PlatformWindow) {
|
func (sm *StateManager) CaptureState(pw PlatformWindow) {
|
||||||
if pw == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
x, y := pw.Position()
|
x, y := pw.Position()
|
||||||
w, h := pw.Size()
|
w, h := pw.Size()
|
||||||
name := pw.Name()
|
sm.SetState(pw.Name(), WindowState{
|
||||||
sm.updateState(name, func(state *WindowState) {
|
X: x, Y: y, Width: w, Height: h,
|
||||||
state.X = x
|
Maximized: pw.IsMaximised(),
|
||||||
state.Y = y
|
|
||||||
state.Width = w
|
|
||||||
state.Height = h
|
|
||||||
state.Maximized = pw.IsMaximised()
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyState restores saved position/size to a Window descriptor.
|
// ApplyState restores saved position/size to a Window descriptor.
|
||||||
|
// Use: sm.ApplyState(&window.Window{Name: "editor"})
|
||||||
func (sm *StateManager) ApplyState(w *Window) {
|
func (sm *StateManager) ApplyState(w *Window) {
|
||||||
s, ok := sm.GetState(w.Name)
|
s, ok := sm.GetState(w.Name)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -219,6 +210,7 @@ func (sm *StateManager) ApplyState(w *Window) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListStates returns all stored window names.
|
// ListStates returns all stored window names.
|
||||||
|
// Use: names := sm.ListStates()
|
||||||
func (sm *StateManager) ListStates() []string {
|
func (sm *StateManager) ListStates() []string {
|
||||||
sm.mu.RLock()
|
sm.mu.RLock()
|
||||||
defer sm.mu.RUnlock()
|
defer sm.mu.RUnlock()
|
||||||
|
|
@ -226,11 +218,11 @@ func (sm *StateManager) ListStates() []string {
|
||||||
for name := range sm.states {
|
for name := range sm.states {
|
||||||
names = append(names, name)
|
names = append(names, name)
|
||||||
}
|
}
|
||||||
sort.Strings(names)
|
|
||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear removes all stored states.
|
// Clear removes all stored states.
|
||||||
|
// Use: sm.Clear()
|
||||||
func (sm *StateManager) Clear() {
|
func (sm *StateManager) Clear() {
|
||||||
sm.mu.Lock()
|
sm.mu.Lock()
|
||||||
sm.states = make(map[string]WindowState)
|
sm.states = make(map[string]WindowState)
|
||||||
|
|
@ -239,9 +231,10 @@ func (sm *StateManager) Clear() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ForceSync writes state to disk immediately.
|
// ForceSync writes state to disk immediately.
|
||||||
|
// Use: sm.ForceSync()
|
||||||
func (sm *StateManager) ForceSync() {
|
func (sm *StateManager) ForceSync() {
|
||||||
sm.mu.Lock()
|
if sm.saveTimer != nil {
|
||||||
sm.stopSaveTimerLocked()
|
sm.saveTimer.Stop()
|
||||||
sm.mu.Unlock()
|
}
|
||||||
sm.save()
|
sm.save()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,22 @@
|
||||||
// pkg/window/tiling.go
|
// pkg/window/tiling.go
|
||||||
package window
|
package window
|
||||||
|
|
||||||
import coreerr "forge.lthn.ai/core/go-log"
|
import "fmt"
|
||||||
|
|
||||||
|
// normalizeWindowForLayout clears transient maximise/minimise state before
|
||||||
|
// applying a new geometry. This keeps layout helpers effective even when a
|
||||||
|
// window was previously maximised.
|
||||||
|
func normalizeWindowForLayout(pw PlatformWindow) {
|
||||||
|
if pw == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if pw.IsMaximised() || pw.IsMinimised() {
|
||||||
|
pw.Restore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TileMode defines how windows are arranged.
|
// TileMode defines how windows are arranged.
|
||||||
|
// Use: mode := window.TileModeLeftRight
|
||||||
type TileMode int
|
type TileMode int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -27,9 +40,12 @@ var tileModeNames = map[TileMode]string{
|
||||||
TileModeLeftRight: "left-right", TileModeGrid: "grid",
|
TileModeLeftRight: "left-right", TileModeGrid: "grid",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// String returns the canonical layout name for the tile mode.
|
||||||
|
// Use: label := window.TileModeGrid.String()
|
||||||
func (m TileMode) String() string { return tileModeNames[m] }
|
func (m TileMode) String() string { return tileModeNames[m] }
|
||||||
|
|
||||||
// SnapPosition defines where a window snaps to.
|
// SnapPosition defines where a window snaps to.
|
||||||
|
// Use: pos := window.SnapRight
|
||||||
type SnapPosition int
|
type SnapPosition int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -44,23 +60,14 @@ const (
|
||||||
SnapCenter
|
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.
|
// WorkflowLayout is a predefined arrangement for common tasks.
|
||||||
|
// Use: workflow := window.WorkflowCoding
|
||||||
type WorkflowLayout int
|
type WorkflowLayout int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
WorkflowCoding WorkflowLayout = iota // 70/30 split
|
WorkflowCoding WorkflowLayout = iota // 70/30 split
|
||||||
WorkflowDebugging // 60/40 split
|
WorkflowDebugging // 60/40 split
|
||||||
WorkflowPresenting // maximized
|
WorkflowPresenting // maximised
|
||||||
WorkflowSideBySide // 50/50 split
|
WorkflowSideBySide // 50/50 split
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -69,38 +76,33 @@ var workflowNames = map[WorkflowLayout]string{
|
||||||
WorkflowPresenting: "presenting", WorkflowSideBySide: "side-by-side",
|
WorkflowPresenting: "presenting", WorkflowSideBySide: "side-by-side",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// String returns the canonical workflow name.
|
||||||
|
// Use: label := window.WorkflowCoding.String()
|
||||||
func (w WorkflowLayout) String() string { return workflowNames[w] }
|
func (w WorkflowLayout) String() string { return workflowNames[w] }
|
||||||
|
|
||||||
func layoutOrigin(origin []int) (int, int) {
|
// ParseWorkflowLayout converts a workflow name into its enum value.
|
||||||
if len(origin) == 0 {
|
// Use: workflow, ok := window.ParseWorkflowLayout("coding")
|
||||||
return 0, 0
|
func ParseWorkflowLayout(name string) (WorkflowLayout, bool) {
|
||||||
|
for workflow, workflowName := range workflowNames {
|
||||||
|
if workflowName == name {
|
||||||
|
return workflow, true
|
||||||
}
|
}
|
||||||
if len(origin) == 1 {
|
|
||||||
return origin[0], 0
|
|
||||||
}
|
}
|
||||||
return origin[0], origin[1]
|
return WorkflowCoding, false
|
||||||
}
|
|
||||||
|
|
||||||
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.
|
// TileWindows arranges the named windows in the given mode across the screen area.
|
||||||
func (m *Manager) TileWindows(mode TileMode, names []string, screenW, screenH int, origin ...int) error {
|
func (m *Manager) TileWindows(mode TileMode, names []string, screenW, screenH int) error {
|
||||||
originX, originY := layoutOrigin(origin)
|
|
||||||
windows := make([]PlatformWindow, 0, len(names))
|
windows := make([]PlatformWindow, 0, len(names))
|
||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
pw, ok := m.Get(name)
|
pw, ok := m.Get(name)
|
||||||
if !ok {
|
if !ok {
|
||||||
return coreerr.E("window.Manager.TileWindows", "window not found: "+name, nil)
|
return fmt.Errorf("window %q not found", name)
|
||||||
}
|
}
|
||||||
windows = append(windows, pw)
|
windows = append(windows, pw)
|
||||||
}
|
}
|
||||||
if len(windows) == 0 {
|
if len(windows) == 0 {
|
||||||
return coreerr.E("window.Manager.TileWindows", "no windows to tile", nil)
|
return fmt.Errorf("no windows to tile")
|
||||||
}
|
}
|
||||||
|
|
||||||
halfW, halfH := screenW/2, screenH/2
|
halfW, halfH := screenW/2, screenH/2
|
||||||
|
|
@ -109,8 +111,9 @@ func (m *Manager) TileWindows(mode TileMode, names []string, screenW, screenH in
|
||||||
case TileModeLeftRight:
|
case TileModeLeftRight:
|
||||||
w := screenW / len(windows)
|
w := screenW / len(windows)
|
||||||
for i, pw := range windows {
|
for i, pw := range windows {
|
||||||
pw.SetBounds(originX+i*w, originY, w, screenH)
|
normalizeWindowForLayout(pw)
|
||||||
m.captureState(pw)
|
pw.SetPosition(i*w, 0)
|
||||||
|
pw.SetSize(w, screenH)
|
||||||
}
|
}
|
||||||
case TileModeGrid:
|
case TileModeGrid:
|
||||||
cols := 2
|
cols := 2
|
||||||
|
|
@ -119,111 +122,133 @@ func (m *Manager) TileWindows(mode TileMode, names []string, screenW, screenH in
|
||||||
}
|
}
|
||||||
cellW := screenW / cols
|
cellW := screenW / cols
|
||||||
for i, pw := range windows {
|
for i, pw := range windows {
|
||||||
|
normalizeWindowForLayout(pw)
|
||||||
row := i / cols
|
row := i / cols
|
||||||
col := i % cols
|
col := i % cols
|
||||||
rows := (len(windows) + cols - 1) / cols
|
rows := (len(windows) + cols - 1) / cols
|
||||||
cellH := screenH / rows
|
cellH := screenH / rows
|
||||||
pw.SetBounds(originX+col*cellW, originY+row*cellH, cellW, cellH)
|
pw.SetPosition(col*cellW, row*cellH)
|
||||||
m.captureState(pw)
|
pw.SetSize(cellW, cellH)
|
||||||
}
|
}
|
||||||
case TileModeLeftHalf:
|
case TileModeLeftHalf:
|
||||||
for _, pw := range windows {
|
for _, pw := range windows {
|
||||||
pw.SetBounds(originX, originY, halfW, screenH)
|
normalizeWindowForLayout(pw)
|
||||||
m.captureState(pw)
|
pw.SetPosition(0, 0)
|
||||||
|
pw.SetSize(halfW, screenH)
|
||||||
}
|
}
|
||||||
case TileModeRightHalf:
|
case TileModeRightHalf:
|
||||||
for _, pw := range windows {
|
for _, pw := range windows {
|
||||||
pw.SetBounds(originX+halfW, originY, halfW, screenH)
|
normalizeWindowForLayout(pw)
|
||||||
m.captureState(pw)
|
pw.SetPosition(halfW, 0)
|
||||||
|
pw.SetSize(halfW, screenH)
|
||||||
}
|
}
|
||||||
case TileModeTopHalf:
|
case TileModeTopHalf:
|
||||||
for _, pw := range windows {
|
for _, pw := range windows {
|
||||||
pw.SetBounds(originX, originY, screenW, halfH)
|
normalizeWindowForLayout(pw)
|
||||||
m.captureState(pw)
|
pw.SetPosition(0, 0)
|
||||||
|
pw.SetSize(screenW, halfH)
|
||||||
}
|
}
|
||||||
case TileModeBottomHalf:
|
case TileModeBottomHalf:
|
||||||
for _, pw := range windows {
|
for _, pw := range windows {
|
||||||
pw.SetBounds(originX, originY+halfH, screenW, halfH)
|
normalizeWindowForLayout(pw)
|
||||||
m.captureState(pw)
|
pw.SetPosition(0, halfH)
|
||||||
|
pw.SetSize(screenW, halfH)
|
||||||
}
|
}
|
||||||
case TileModeTopLeft:
|
case TileModeTopLeft:
|
||||||
for _, pw := range windows {
|
for _, pw := range windows {
|
||||||
pw.SetBounds(originX, originY, halfW, halfH)
|
normalizeWindowForLayout(pw)
|
||||||
m.captureState(pw)
|
pw.SetPosition(0, 0)
|
||||||
|
pw.SetSize(halfW, halfH)
|
||||||
}
|
}
|
||||||
case TileModeTopRight:
|
case TileModeTopRight:
|
||||||
for _, pw := range windows {
|
for _, pw := range windows {
|
||||||
pw.SetBounds(originX+halfW, originY, halfW, halfH)
|
normalizeWindowForLayout(pw)
|
||||||
m.captureState(pw)
|
pw.SetPosition(halfW, 0)
|
||||||
|
pw.SetSize(halfW, halfH)
|
||||||
}
|
}
|
||||||
case TileModeBottomLeft:
|
case TileModeBottomLeft:
|
||||||
for _, pw := range windows {
|
for _, pw := range windows {
|
||||||
pw.SetBounds(originX, originY+halfH, halfW, halfH)
|
normalizeWindowForLayout(pw)
|
||||||
m.captureState(pw)
|
pw.SetPosition(0, halfH)
|
||||||
|
pw.SetSize(halfW, halfH)
|
||||||
}
|
}
|
||||||
case TileModeBottomRight:
|
case TileModeBottomRight:
|
||||||
for _, pw := range windows {
|
for _, pw := range windows {
|
||||||
pw.SetBounds(originX+halfW, originY+halfH, halfW, halfH)
|
normalizeWindowForLayout(pw)
|
||||||
m.captureState(pw)
|
pw.SetPosition(halfW, halfH)
|
||||||
|
pw.SetSize(halfW, halfH)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SnapWindow snaps a window to a screen edge, corner, or center.
|
// SnapWindow snaps a window to a screen edge/corner/centre.
|
||||||
func (m *Manager) SnapWindow(name string, pos SnapPosition, screenW, screenH int, origin ...int) error {
|
func (m *Manager) SnapWindow(name string, pos SnapPosition, screenW, screenH int) error {
|
||||||
originX, originY := layoutOrigin(origin)
|
|
||||||
pw, ok := m.Get(name)
|
pw, ok := m.Get(name)
|
||||||
if !ok {
|
if !ok {
|
||||||
return coreerr.E("window.Manager.SnapWindow", "window not found: "+name, nil)
|
return fmt.Errorf("window %q not found", name)
|
||||||
}
|
}
|
||||||
|
|
||||||
halfW, halfH := screenW/2, screenH/2
|
halfW, halfH := screenW/2, screenH/2
|
||||||
|
|
||||||
switch pos {
|
switch pos {
|
||||||
case SnapLeft:
|
case SnapLeft:
|
||||||
pw.SetBounds(originX, originY, halfW, screenH)
|
normalizeWindowForLayout(pw)
|
||||||
|
pw.SetPosition(0, 0)
|
||||||
|
pw.SetSize(halfW, screenH)
|
||||||
case SnapRight:
|
case SnapRight:
|
||||||
pw.SetBounds(originX+halfW, originY, halfW, screenH)
|
normalizeWindowForLayout(pw)
|
||||||
|
pw.SetPosition(halfW, 0)
|
||||||
|
pw.SetSize(halfW, screenH)
|
||||||
case SnapTop:
|
case SnapTop:
|
||||||
pw.SetBounds(originX, originY, screenW, halfH)
|
normalizeWindowForLayout(pw)
|
||||||
|
pw.SetPosition(0, 0)
|
||||||
|
pw.SetSize(screenW, halfH)
|
||||||
case SnapBottom:
|
case SnapBottom:
|
||||||
pw.SetBounds(originX, originY+halfH, screenW, halfH)
|
normalizeWindowForLayout(pw)
|
||||||
|
pw.SetPosition(0, halfH)
|
||||||
|
pw.SetSize(screenW, halfH)
|
||||||
case SnapTopLeft:
|
case SnapTopLeft:
|
||||||
pw.SetBounds(originX, originY, halfW, halfH)
|
normalizeWindowForLayout(pw)
|
||||||
|
pw.SetPosition(0, 0)
|
||||||
|
pw.SetSize(halfW, halfH)
|
||||||
case SnapTopRight:
|
case SnapTopRight:
|
||||||
pw.SetBounds(originX+halfW, originY, halfW, halfH)
|
normalizeWindowForLayout(pw)
|
||||||
|
pw.SetPosition(halfW, 0)
|
||||||
|
pw.SetSize(halfW, halfH)
|
||||||
case SnapBottomLeft:
|
case SnapBottomLeft:
|
||||||
pw.SetBounds(originX, originY+halfH, halfW, halfH)
|
normalizeWindowForLayout(pw)
|
||||||
|
pw.SetPosition(0, halfH)
|
||||||
|
pw.SetSize(halfW, halfH)
|
||||||
case SnapBottomRight:
|
case SnapBottomRight:
|
||||||
pw.SetBounds(originX+halfW, originY+halfH, halfW, halfH)
|
normalizeWindowForLayout(pw)
|
||||||
|
pw.SetPosition(halfW, halfH)
|
||||||
|
pw.SetSize(halfW, halfH)
|
||||||
case SnapCenter:
|
case SnapCenter:
|
||||||
|
normalizeWindowForLayout(pw)
|
||||||
cw, ch := pw.Size()
|
cw, ch := pw.Size()
|
||||||
pw.SetBounds(originX+(screenW-cw)/2, originY+(screenH-ch)/2, cw, ch)
|
pw.SetPosition((screenW-cw)/2, (screenH-ch)/2)
|
||||||
}
|
}
|
||||||
m.captureState(pw)
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// StackWindows cascades windows with an offset.
|
// StackWindows cascades windows with an offset.
|
||||||
func (m *Manager) StackWindows(names []string, offsetX, offsetY int, origin ...int) error {
|
func (m *Manager) StackWindows(names []string, offsetX, offsetY int) error {
|
||||||
originX, originY := layoutOrigin(origin)
|
|
||||||
for i, name := range names {
|
for i, name := range names {
|
||||||
pw, ok := m.Get(name)
|
pw, ok := m.Get(name)
|
||||||
if !ok {
|
if !ok {
|
||||||
return coreerr.E("window.Manager.StackWindows", "window not found: "+name, nil)
|
return fmt.Errorf("window %q not found", name)
|
||||||
}
|
}
|
||||||
pw.SetPosition(originX+i*offsetX, originY+i*offsetY)
|
normalizeWindowForLayout(pw)
|
||||||
m.captureState(pw)
|
pw.SetPosition(i*offsetX, i*offsetY)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ApplyWorkflow arranges windows in a predefined workflow layout.
|
// ApplyWorkflow arranges windows in a predefined workflow layout.
|
||||||
func (m *Manager) ApplyWorkflow(workflow WorkflowLayout, names []string, screenW, screenH int, origin ...int) error {
|
func (m *Manager) ApplyWorkflow(workflow WorkflowLayout, names []string, screenW, screenH int) error {
|
||||||
originX, originY := layoutOrigin(origin)
|
|
||||||
if len(names) == 0 {
|
if len(names) == 0 {
|
||||||
return coreerr.E("window.Manager.ApplyWorkflow", "no windows for workflow", nil)
|
return fmt.Errorf("no windows for workflow")
|
||||||
}
|
}
|
||||||
|
|
||||||
switch workflow {
|
switch workflow {
|
||||||
|
|
@ -231,36 +256,41 @@ func (m *Manager) ApplyWorkflow(workflow WorkflowLayout, names []string, screenW
|
||||||
// 70/30 split — main editor + terminal
|
// 70/30 split — main editor + terminal
|
||||||
mainW := screenW * 70 / 100
|
mainW := screenW * 70 / 100
|
||||||
if pw, ok := m.Get(names[0]); ok {
|
if pw, ok := m.Get(names[0]); ok {
|
||||||
pw.SetBounds(originX, originY, mainW, screenH)
|
normalizeWindowForLayout(pw)
|
||||||
m.captureState(pw)
|
pw.SetPosition(0, 0)
|
||||||
|
pw.SetSize(mainW, screenH)
|
||||||
}
|
}
|
||||||
if len(names) > 1 {
|
if len(names) > 1 {
|
||||||
if pw, ok := m.Get(names[1]); ok {
|
if pw, ok := m.Get(names[1]); ok {
|
||||||
pw.SetBounds(originX+mainW, originY, screenW-mainW, screenH)
|
normalizeWindowForLayout(pw)
|
||||||
m.captureState(pw)
|
pw.SetPosition(mainW, 0)
|
||||||
|
pw.SetSize(screenW-mainW, screenH)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case WorkflowDebugging:
|
case WorkflowDebugging:
|
||||||
// 60/40 split
|
// 60/40 split
|
||||||
mainW := screenW * 60 / 100
|
mainW := screenW * 60 / 100
|
||||||
if pw, ok := m.Get(names[0]); ok {
|
if pw, ok := m.Get(names[0]); ok {
|
||||||
pw.SetBounds(originX, originY, mainW, screenH)
|
normalizeWindowForLayout(pw)
|
||||||
m.captureState(pw)
|
pw.SetPosition(0, 0)
|
||||||
|
pw.SetSize(mainW, screenH)
|
||||||
}
|
}
|
||||||
if len(names) > 1 {
|
if len(names) > 1 {
|
||||||
if pw, ok := m.Get(names[1]); ok {
|
if pw, ok := m.Get(names[1]); ok {
|
||||||
pw.SetBounds(originX+mainW, originY, screenW-mainW, screenH)
|
normalizeWindowForLayout(pw)
|
||||||
m.captureState(pw)
|
pw.SetPosition(mainW, 0)
|
||||||
|
pw.SetSize(screenW-mainW, screenH)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case WorkflowPresenting:
|
case WorkflowPresenting:
|
||||||
// Maximize first window
|
// Maximise first window
|
||||||
if pw, ok := m.Get(names[0]); ok {
|
if pw, ok := m.Get(names[0]); ok {
|
||||||
pw.SetBounds(originX, originY, screenW, screenH)
|
normalizeWindowForLayout(pw)
|
||||||
m.captureState(pw)
|
pw.SetPosition(0, 0)
|
||||||
|
pw.SetSize(screenW, screenH)
|
||||||
}
|
}
|
||||||
case WorkflowSideBySide:
|
case WorkflowSideBySide:
|
||||||
return m.TileWindows(TileModeLeftRight, names, screenW, screenH, originX, originY)
|
return m.TileWindows(TileModeLeftRight, names, screenW, screenH)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,48 +7,54 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// WailsPlatform implements Platform using Wails v3.
|
// WailsPlatform implements Platform using Wails v3.
|
||||||
|
// Use: platform := window.NewWailsPlatform(app)
|
||||||
type WailsPlatform struct {
|
type WailsPlatform struct {
|
||||||
app *application.App
|
app *application.App
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWailsPlatform returns a Platform backed by a Wails app.
|
// NewWailsPlatform creates a Wails-backed Platform.
|
||||||
|
// Use: platform := window.NewWailsPlatform(app)
|
||||||
func NewWailsPlatform(app *application.App) *WailsPlatform {
|
func NewWailsPlatform(app *application.App) *WailsPlatform {
|
||||||
return &WailsPlatform{app: app}
|
return &WailsPlatform{app: app}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (platform *WailsPlatform) CreateWindow(options PlatformWindowOptions) PlatformWindow {
|
// CreateWindow opens a new Wails window from platform options.
|
||||||
windowOptions := application.WebviewWindowOptions{
|
// Use: w := wp.CreateWindow(window.PlatformWindowOptions{Name: "editor", URL: "/editor"})
|
||||||
Name: options.Name,
|
func (wp *WailsPlatform) CreateWindow(opts PlatformWindowOptions) PlatformWindow {
|
||||||
Title: options.Title,
|
wOpts := application.WebviewWindowOptions{
|
||||||
URL: options.URL,
|
Name: opts.Name,
|
||||||
Width: options.Width,
|
Title: opts.Title,
|
||||||
Height: options.Height,
|
URL: opts.URL,
|
||||||
X: options.X,
|
Width: opts.Width,
|
||||||
Y: options.Y,
|
Height: opts.Height,
|
||||||
MinWidth: options.MinWidth,
|
X: opts.X,
|
||||||
MinHeight: options.MinHeight,
|
Y: opts.Y,
|
||||||
MaxWidth: options.MaxWidth,
|
MinWidth: opts.MinWidth,
|
||||||
MaxHeight: options.MaxHeight,
|
MinHeight: opts.MinHeight,
|
||||||
Frameless: options.Frameless,
|
MaxWidth: opts.MaxWidth,
|
||||||
Hidden: options.Hidden,
|
MaxHeight: opts.MaxHeight,
|
||||||
AlwaysOnTop: options.AlwaysOnTop,
|
Frameless: opts.Frameless,
|
||||||
DisableResize: options.DisableResize,
|
Hidden: opts.Hidden,
|
||||||
EnableFileDrop: options.EnableFileDrop,
|
AlwaysOnTop: opts.AlwaysOnTop,
|
||||||
BackgroundColour: application.NewRGBA(options.BackgroundColour[0], options.BackgroundColour[1], options.BackgroundColour[2], options.BackgroundColour[3]),
|
DisableResize: opts.DisableResize,
|
||||||
|
EnableFileDrop: opts.EnableFileDrop,
|
||||||
|
BackgroundColour: application.NewRGBA(opts.BackgroundColour[0], opts.BackgroundColour[1], opts.BackgroundColour[2], opts.BackgroundColour[3]),
|
||||||
}
|
}
|
||||||
windowHandle := platform.app.Window.NewWithOptions(windowOptions)
|
w := wp.app.Window.NewWithOptions(wOpts)
|
||||||
return &wailsWindow{w: windowHandle, title: options.Title}
|
return &wailsWindow{w: w, title: opts.Title}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (platform *WailsPlatform) GetWindows() []PlatformWindow {
|
// GetWindows returns the live Wails windows.
|
||||||
allWindows := platform.app.Window.GetAll()
|
// Use: windows := wp.GetWindows()
|
||||||
platformWindows := make([]PlatformWindow, 0, len(allWindows))
|
func (wp *WailsPlatform) GetWindows() []PlatformWindow {
|
||||||
for _, window := range allWindows {
|
all := wp.app.Window.GetAll()
|
||||||
if windowHandle, ok := window.(*application.WebviewWindow); ok {
|
out := make([]PlatformWindow, 0, len(all))
|
||||||
platformWindows = append(platformWindows, &wailsWindow{w: windowHandle})
|
for _, w := range all {
|
||||||
|
if wv, ok := w.(*application.WebviewWindow); ok {
|
||||||
|
out = append(out, &wailsWindow{w: wv, title: wv.Name()})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return platformWindows
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// wailsWindow wraps *application.WebviewWindow to implement PlatformWindow.
|
// wailsWindow wraps *application.WebviewWindow to implement PlatformWindow.
|
||||||
|
|
@ -58,52 +64,54 @@ type wailsWindow struct {
|
||||||
title string
|
title string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (windowHandle *wailsWindow) Name() string { return windowHandle.w.Name() }
|
func (ww *wailsWindow) Name() string { return ww.w.Name() }
|
||||||
func (windowHandle *wailsWindow) Title() string { return windowHandle.title }
|
func (ww *wailsWindow) Title() string {
|
||||||
func (windowHandle *wailsWindow) Position() (int, int) { return windowHandle.w.Position() }
|
if ww.title != "" {
|
||||||
func (windowHandle *wailsWindow) Size() (int, int) { return windowHandle.w.Size() }
|
return ww.title
|
||||||
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 (windowHandle *wailsWindow) SetBounds(x, y, width, height int) {
|
if ww.w != nil {
|
||||||
windowHandle.w.SetPosition(x, y)
|
return ww.w.Name()
|
||||||
windowHandle.w.SetSize(width, height)
|
|
||||||
}
|
}
|
||||||
func (windowHandle *wailsWindow) SetPosition(x, y int) { windowHandle.w.SetPosition(x, y) }
|
return ""
|
||||||
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) {
|
func (ww *wailsWindow) Position() (int, int) { return ww.w.Position() }
|
||||||
|
func (ww *wailsWindow) Size() (int, int) { return ww.w.Size() }
|
||||||
|
func (ww *wailsWindow) IsVisible() bool { return ww.w.IsVisible() }
|
||||||
|
func (ww *wailsWindow) IsMinimised() bool { return ww.w.IsMinimised() }
|
||||||
|
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 (ww *wailsWindow) SetOpacity(opacity float32) { ww.w.SetOpacity(opacity) }
|
||||||
|
func (ww *wailsWindow) SetVisibility(visible bool) {
|
||||||
if visible {
|
if visible {
|
||||||
windowHandle.w.Show()
|
ww.w.Show()
|
||||||
} else {
|
} else {
|
||||||
windowHandle.w.Hide()
|
ww.w.Hide()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
func (windowHandle *wailsWindow) SetAlwaysOnTop(alwaysOnTop bool) {
|
func (ww *wailsWindow) SetAlwaysOnTop(alwaysOnTop bool) { ww.w.SetAlwaysOnTop(alwaysOnTop) }
|
||||||
windowHandle.w.SetAlwaysOnTop(alwaysOnTop)
|
func (ww *wailsWindow) Maximise() { ww.w.Maximise() }
|
||||||
}
|
func (ww *wailsWindow) Restore() { ww.w.Restore() }
|
||||||
func (windowHandle *wailsWindow) Maximise() { windowHandle.w.Maximise() }
|
func (ww *wailsWindow) Minimise() { ww.w.Minimise() }
|
||||||
func (windowHandle *wailsWindow) Restore() { windowHandle.w.Restore() }
|
func (ww *wailsWindow) Focus() { ww.w.Focus() }
|
||||||
func (windowHandle *wailsWindow) Minimise() { windowHandle.w.Minimise() }
|
func (ww *wailsWindow) Close() { ww.w.Close() }
|
||||||
func (windowHandle *wailsWindow) Focus() { windowHandle.w.Focus() }
|
func (ww *wailsWindow) Show() { ww.w.Show() }
|
||||||
func (windowHandle *wailsWindow) Close() { windowHandle.w.Close() }
|
func (ww *wailsWindow) Hide() { ww.w.Hide() }
|
||||||
func (windowHandle *wailsWindow) Show() { windowHandle.w.Show() }
|
func (ww *wailsWindow) Fullscreen() { ww.w.Fullscreen() }
|
||||||
func (windowHandle *wailsWindow) Hide() { windowHandle.w.Hide() }
|
func (ww *wailsWindow) UnFullscreen() { ww.w.UnFullscreen() }
|
||||||
func (windowHandle *wailsWindow) Fullscreen() { windowHandle.w.Fullscreen() }
|
func (ww *wailsWindow) OpenDevTools() { ww.w.OpenDevTools() }
|
||||||
func (windowHandle *wailsWindow) UnFullscreen() { windowHandle.w.UnFullscreen() }
|
func (ww *wailsWindow) CloseDevTools() { ww.w.CloseDevTools() }
|
||||||
|
|
||||||
func (windowHandle *wailsWindow) OnWindowEvent(handler func(event WindowEvent)) {
|
func (ww *wailsWindow) OnWindowEvent(handler func(event WindowEvent)) {
|
||||||
name := windowHandle.w.Name()
|
name := ww.w.Name()
|
||||||
|
|
||||||
// Map common Wails window events to our WindowEvent type.
|
// Map common Wails window events to our WindowEvent type.
|
||||||
windowEventMap := map[events.WindowEventType]string{
|
eventMap := map[events.WindowEventType]string{
|
||||||
events.Common.WindowFocus: "focus",
|
events.Common.WindowFocus: "focus",
|
||||||
events.Common.WindowLostFocus: "blur",
|
events.Common.WindowLostFocus: "blur",
|
||||||
events.Common.WindowDidMove: "move",
|
events.Common.WindowDidMove: "move",
|
||||||
|
|
@ -111,22 +119,22 @@ func (windowHandle *wailsWindow) OnWindowEvent(handler func(event WindowEvent))
|
||||||
events.Common.WindowClosing: "close",
|
events.Common.WindowClosing: "close",
|
||||||
}
|
}
|
||||||
|
|
||||||
for eventType, eventName := range windowEventMap {
|
for eventType, eventName := range eventMap {
|
||||||
mappedEventName := eventName // capture for closure
|
typeName := eventName // capture for closure
|
||||||
windowHandle.w.OnWindowEvent(eventType, func(event *application.WindowEvent) {
|
ww.w.OnWindowEvent(eventType, func(event *application.WindowEvent) {
|
||||||
data := make(map[string]any)
|
data := make(map[string]any)
|
||||||
switch mappedEventName {
|
switch typeName {
|
||||||
case "move":
|
case "move":
|
||||||
x, y := windowHandle.w.Position()
|
x, y := ww.w.Position()
|
||||||
data["x"] = x
|
data["x"] = x
|
||||||
data["y"] = y
|
data["y"] = y
|
||||||
case "resize":
|
case "resize":
|
||||||
width, height := windowHandle.w.Size()
|
w, h := ww.w.Size()
|
||||||
data["width"] = width
|
data["w"] = w
|
||||||
data["height"] = height
|
data["h"] = h
|
||||||
}
|
}
|
||||||
handler(WindowEvent{
|
handler(WindowEvent{
|
||||||
Type: mappedEventName,
|
Type: typeName,
|
||||||
Name: name,
|
Name: name,
|
||||||
Data: data,
|
Data: data,
|
||||||
})
|
})
|
||||||
|
|
@ -134,8 +142,8 @@ func (windowHandle *wailsWindow) OnWindowEvent(handler func(event WindowEvent))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (windowHandle *wailsWindow) OnFileDrop(handler func(paths []string, targetID string)) {
|
func (ww *wailsWindow) OnFileDrop(handler func(paths []string, targetID string)) {
|
||||||
windowHandle.w.OnWindowEvent(events.Common.WindowFilesDropped, func(event *application.WindowEvent) {
|
ww.w.OnWindowEvent(events.Common.WindowFilesDropped, func(event *application.WindowEvent) {
|
||||||
files := event.Context().DroppedFiles()
|
files := event.Context().DroppedFiles()
|
||||||
details := event.Context().DropTargetDetails()
|
details := event.Context().DropTargetDetails()
|
||||||
targetID := ""
|
targetID := ""
|
||||||
|
|
|
||||||
|
|
@ -2,32 +2,31 @@
|
||||||
package window
|
package window
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sort"
|
"fmt"
|
||||||
|
"math"
|
||||||
"sync"
|
"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.
|
||||||
|
// Use: spec := &window.Window{Name: "editor", URL: "/editor"}
|
||||||
type Window struct {
|
type Window struct {
|
||||||
Name string `json:"name,omitempty"`
|
Name string
|
||||||
Title string `json:"title,omitempty"`
|
Title string
|
||||||
URL string `json:"url,omitempty"`
|
URL string
|
||||||
Width int `json:"width,omitempty"`
|
Width, Height int
|
||||||
Height int `json:"height,omitempty"`
|
X, Y int
|
||||||
X int `json:"x,omitempty"`
|
MinWidth, MinHeight int
|
||||||
Y int `json:"y,omitempty"`
|
MaxWidth, MaxHeight int
|
||||||
MinWidth int `json:"minWidth,omitempty"`
|
Frameless bool
|
||||||
MinHeight int `json:"minHeight,omitempty"`
|
Hidden bool
|
||||||
MaxWidth int `json:"maxWidth,omitempty"`
|
AlwaysOnTop bool
|
||||||
MaxHeight int `json:"maxHeight,omitempty"`
|
BackgroundColour [4]uint8
|
||||||
Frameless bool `json:"frameless,omitempty"`
|
DisableResize bool
|
||||||
Hidden bool `json:"hidden,omitempty"`
|
EnableFileDrop bool
|
||||||
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.
|
// ToPlatformOptions converts a Window to PlatformWindowOptions for the backend.
|
||||||
|
// Use: opts := spec.ToPlatformOptions()
|
||||||
func (w *Window) ToPlatformOptions() PlatformWindowOptions {
|
func (w *Window) ToPlatformOptions() PlatformWindowOptions {
|
||||||
return PlatformWindowOptions{
|
return PlatformWindowOptions{
|
||||||
Name: w.Name, Title: w.Title, URL: w.URL,
|
Name: w.Name, Title: w.Title, URL: w.URL,
|
||||||
|
|
@ -41,6 +40,7 @@ func (w *Window) ToPlatformOptions() PlatformWindowOptions {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Manager manages window lifecycle through a Platform backend.
|
// Manager manages window lifecycle through a Platform backend.
|
||||||
|
// Use: mgr := window.NewManager(platform)
|
||||||
type Manager struct {
|
type Manager struct {
|
||||||
platform Platform
|
platform Platform
|
||||||
state *StateManager
|
state *StateManager
|
||||||
|
|
@ -52,6 +52,7 @@ type Manager struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewManager creates a window Manager with the given platform backend.
|
// NewManager creates a window Manager with the given platform backend.
|
||||||
|
// Use: mgr := window.NewManager(platform)
|
||||||
func NewManager(platform Platform) *Manager {
|
func NewManager(platform Platform) *Manager {
|
||||||
return &Manager{
|
return &Manager{
|
||||||
platform: platform,
|
platform: platform,
|
||||||
|
|
@ -62,7 +63,7 @@ func NewManager(platform Platform) *Manager {
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewManagerWithDir creates a window Manager with a custom config directory for state/layout persistence.
|
// NewManagerWithDir creates a window Manager with a custom config directory for state/layout persistence.
|
||||||
// Useful for testing or when the default config directory is not appropriate.
|
// Use: mgr := window.NewManagerWithDir(platform, t.TempDir())
|
||||||
func NewManagerWithDir(platform Platform, configDir string) *Manager {
|
func NewManagerWithDir(platform Platform, configDir string) *Manager {
|
||||||
return &Manager{
|
return &Manager{
|
||||||
platform: platform,
|
platform: platform,
|
||||||
|
|
@ -72,23 +73,35 @@ func NewManagerWithDir(platform Platform, configDir string) *Manager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetDefaultWidth overrides the fallback width used when a window is created without one.
|
||||||
|
// Use: mgr.SetDefaultWidth(1280)
|
||||||
func (m *Manager) SetDefaultWidth(width int) {
|
func (m *Manager) SetDefaultWidth(width int) {
|
||||||
if width > 0 {
|
if width > 0 {
|
||||||
m.defaultWidth = width
|
m.defaultWidth = width
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetDefaultHeight overrides the fallback height used when a window is created without one.
|
||||||
|
// Use: mgr.SetDefaultHeight(800)
|
||||||
func (m *Manager) SetDefaultHeight(height int) {
|
func (m *Manager) SetDefaultHeight(height int) {
|
||||||
if height > 0 {
|
if height > 0 {
|
||||||
m.defaultHeight = height
|
m.defaultHeight = height
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create opens a window from a declarative spec.
|
// Open creates a window using functional options, applies saved state, and tracks it.
|
||||||
// Example: m.Create(Window{Name: "editor", Title: "Editor", URL: "/"})
|
// Use: _, err := mgr.Open(window.WithName("editor"), window.WithURL("/editor"))
|
||||||
// Saved position, size, and maximized state are restored when available.
|
func (m *Manager) Open(opts ...WindowOption) (PlatformWindow, error) {
|
||||||
func (m *Manager) Create(spec Window) (PlatformWindow, error) {
|
w, err := ApplyOptions(opts...)
|
||||||
w := spec
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("window.Manager.Open: %w", err)
|
||||||
|
}
|
||||||
|
return m.Create(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create creates a window from a Window descriptor.
|
||||||
|
// Use: _, err := mgr.Create(&window.Window{Name: "editor", URL: "/editor"})
|
||||||
|
func (m *Manager) Create(w *Window) (PlatformWindow, error) {
|
||||||
if w.Name == "" {
|
if w.Name == "" {
|
||||||
w.Name = "main"
|
w.Name = "main"
|
||||||
}
|
}
|
||||||
|
|
@ -113,17 +126,10 @@ func (m *Manager) Create(spec Window) (PlatformWindow, error) {
|
||||||
w.URL = "/"
|
w.URL = "/"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply saved state if available.
|
// Apply saved state if available
|
||||||
if m.state != nil {
|
m.state.ApplyState(w)
|
||||||
m.state.ApplyState(&w)
|
|
||||||
}
|
|
||||||
|
|
||||||
pw := m.platform.CreateWindow(w.ToPlatformOptions())
|
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.mu.Lock()
|
||||||
m.windows[w.Name] = pw
|
m.windows[w.Name] = pw
|
||||||
|
|
@ -133,6 +139,7 @@ func (m *Manager) Create(spec Window) (PlatformWindow, error) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get returns a tracked window by name.
|
// Get returns a tracked window by name.
|
||||||
|
// Use: pw, ok := mgr.Get("editor")
|
||||||
func (m *Manager) Get(name string) (PlatformWindow, bool) {
|
func (m *Manager) Get(name string) (PlatformWindow, bool) {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
@ -141,6 +148,7 @@ func (m *Manager) Get(name string) (PlatformWindow, bool) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// List returns all tracked window names.
|
// List returns all tracked window names.
|
||||||
|
// Use: names := mgr.List()
|
||||||
func (m *Manager) List() []string {
|
func (m *Manager) List() []string {
|
||||||
m.mu.RLock()
|
m.mu.RLock()
|
||||||
defer m.mu.RUnlock()
|
defer m.mu.RUnlock()
|
||||||
|
|
@ -148,11 +156,11 @@ func (m *Manager) List() []string {
|
||||||
for name := range m.windows {
|
for name := range m.windows {
|
||||||
names = append(names, name)
|
names = append(names, name)
|
||||||
}
|
}
|
||||||
sort.Strings(names)
|
|
||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove stops tracking a window by name.
|
// Remove stops tracking a window by name.
|
||||||
|
// Use: mgr.Remove("editor")
|
||||||
func (m *Manager) Remove(name string) {
|
func (m *Manager) Remove(name string) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
delete(m.windows, name)
|
delete(m.windows, name)
|
||||||
|
|
@ -160,16 +168,171 @@ func (m *Manager) Remove(name string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Platform returns the underlying platform for direct access.
|
// Platform returns the underlying platform for direct access.
|
||||||
|
// Use: platform := mgr.Platform()
|
||||||
func (m *Manager) Platform() Platform {
|
func (m *Manager) Platform() Platform {
|
||||||
return m.platform
|
return m.platform
|
||||||
}
|
}
|
||||||
|
|
||||||
// State returns the state manager for window persistence.
|
// State returns the state manager for window persistence.
|
||||||
|
// Use: state := mgr.State()
|
||||||
func (m *Manager) State() *StateManager {
|
func (m *Manager) State() *StateManager {
|
||||||
return m.state
|
return m.state
|
||||||
}
|
}
|
||||||
|
|
||||||
// Layout returns the layout manager.
|
// Layout returns the layout manager.
|
||||||
|
// Use: layouts := mgr.Layout()
|
||||||
func (m *Manager) Layout() *LayoutManager {
|
func (m *Manager) Layout() *LayoutManager {
|
||||||
return m.layout
|
return m.layout
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SuggestLayout returns a simple layout recommendation for the given screen.
|
||||||
|
// Use: suggestion := mgr.SuggestLayout(1920, 1080, 2)
|
||||||
|
func (m *Manager) SuggestLayout(screenW, screenH, windowCount int) LayoutSuggestion {
|
||||||
|
if windowCount <= 1 {
|
||||||
|
return LayoutSuggestion{
|
||||||
|
Mode: "single",
|
||||||
|
Columns: 1,
|
||||||
|
Rows: 1,
|
||||||
|
PrimaryWidth: screenW,
|
||||||
|
SecondaryWidth: 0,
|
||||||
|
Description: "Focus the primary window and keep the screen uncluttered.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if windowCount == 2 {
|
||||||
|
return LayoutSuggestion{
|
||||||
|
Mode: "side-by-side",
|
||||||
|
Columns: 2,
|
||||||
|
Rows: 1,
|
||||||
|
PrimaryWidth: screenW / 2,
|
||||||
|
SecondaryWidth: screenW - (screenW / 2),
|
||||||
|
Description: "Split the screen into two equal panes.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if windowCount <= 4 {
|
||||||
|
return LayoutSuggestion{
|
||||||
|
Mode: "quadrants",
|
||||||
|
Columns: 2,
|
||||||
|
Rows: 2,
|
||||||
|
PrimaryWidth: screenW / 2,
|
||||||
|
SecondaryWidth: screenW / 2,
|
||||||
|
Description: "Use a 2x2 grid for the active windows.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cols := 3
|
||||||
|
rows := int(math.Ceil(float64(windowCount) / float64(cols)))
|
||||||
|
return LayoutSuggestion{
|
||||||
|
Mode: "grid",
|
||||||
|
Columns: cols,
|
||||||
|
Rows: rows,
|
||||||
|
PrimaryWidth: screenW / cols,
|
||||||
|
SecondaryWidth: screenW / cols,
|
||||||
|
Description: "Use a dense grid to keep every window visible.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindSpace returns a free placement suggestion for a new window.
|
||||||
|
// Use: info := mgr.FindSpace(1920, 1080, 1280, 800)
|
||||||
|
func (m *Manager) FindSpace(screenW, screenH, width, height int) SpaceInfo {
|
||||||
|
if width <= 0 {
|
||||||
|
width = screenW / 2
|
||||||
|
}
|
||||||
|
if height <= 0 {
|
||||||
|
height = screenH / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
occupied := make([]struct {
|
||||||
|
x, y, w, h int
|
||||||
|
}, 0)
|
||||||
|
for _, name := range m.List() {
|
||||||
|
pw, ok := m.Get(name)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
x, y := pw.Position()
|
||||||
|
w, h := pw.Size()
|
||||||
|
occupied = append(occupied, struct {
|
||||||
|
x, y, w, h int
|
||||||
|
}{x: x, y: y, w: w, h: h})
|
||||||
|
}
|
||||||
|
|
||||||
|
step := int(math.Max(40, math.Min(float64(width), float64(height))/6))
|
||||||
|
if step < 40 {
|
||||||
|
step = 40
|
||||||
|
}
|
||||||
|
|
||||||
|
for y := 0; y+height <= screenH; y += step {
|
||||||
|
for x := 0; x+width <= screenW; x += step {
|
||||||
|
if !intersectsAny(x, y, width, height, occupied) {
|
||||||
|
return SpaceInfo{
|
||||||
|
X: x, Y: y, Width: width, Height: height,
|
||||||
|
ScreenWidth: screenW, ScreenHeight: screenH,
|
||||||
|
Reason: "first available gap",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return SpaceInfo{
|
||||||
|
X: (screenW - width) / 2, Y: (screenH - height) / 2,
|
||||||
|
Width: width, Height: height,
|
||||||
|
ScreenWidth: screenW, ScreenHeight: screenH,
|
||||||
|
Reason: "center fallback",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArrangePair places two windows side-by-side with a balanced split.
|
||||||
|
// Use: _ = mgr.ArrangePair("editor", "terminal", 1920, 1080)
|
||||||
|
func (m *Manager) ArrangePair(first, second string, screenW, screenH int) error {
|
||||||
|
left, ok := m.Get(first)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("window %q not found", first)
|
||||||
|
}
|
||||||
|
right, ok := m.Get(second)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("window %q not found", second)
|
||||||
|
}
|
||||||
|
|
||||||
|
leftW := screenW / 2
|
||||||
|
rightW := screenW - leftW
|
||||||
|
left.SetPosition(0, 0)
|
||||||
|
left.SetSize(leftW, screenH)
|
||||||
|
right.SetPosition(leftW, 0)
|
||||||
|
right.SetSize(rightW, screenH)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BesideEditor places a target window beside an editor window, using a 70/30 split.
|
||||||
|
// Use: _ = mgr.BesideEditor("editor", "terminal", 1920, 1080)
|
||||||
|
func (m *Manager) BesideEditor(editorName, windowName string, screenW, screenH int) error {
|
||||||
|
editor, ok := m.Get(editorName)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("window %q not found", editorName)
|
||||||
|
}
|
||||||
|
target, ok := m.Get(windowName)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("window %q not found", windowName)
|
||||||
|
}
|
||||||
|
|
||||||
|
editorW := screenW * 70 / 100
|
||||||
|
if editorW <= 0 {
|
||||||
|
editorW = screenW / 2
|
||||||
|
}
|
||||||
|
targetW := screenW - editorW
|
||||||
|
editor.SetPosition(0, 0)
|
||||||
|
editor.SetSize(editorW, screenH)
|
||||||
|
target.SetPosition(editorW, 0)
|
||||||
|
target.SetSize(targetW, screenH)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func intersectsAny(x, y, w, h int, occupied []struct{ x, y, w, h int }) bool {
|
||||||
|
for _, r := range occupied {
|
||||||
|
if x < r.x+r.w && x+w > r.x && y < r.y+r.h && y+h > r.y {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue