Compare commits

..

46 commits
main ... dev

Author SHA1 Message Date
Snider
873eafe7b6 fix: migrate module paths from forge.lthn.ai to dappco.re
Some checks failed
Security Scan / security (push) Has been cancelled
Test / test (push) Has been cancelled
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-04 16:21:14 +01:00
Virgil
031c286fb9 Align GUI packages with AX conventions
Some checks failed
Security Scan / security (push) Has been cancelled
Test / test (push) Has been cancelled
2026-04-02 20:51:26 +00:00
Virgil
6b3879fb9a refactor(ax): make webview registration declarative
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 20:47:33 +00:00
Virgil
d9491380f8 chore(gui): align remaining AX usage examples
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 20:42:42 +00:00
Virgil
98b73fc14c refactor(display): add AX usage examples
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 20:39:36 +00:00
Virgil
274a81689c chore(gui): add AX usage examples
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 20:36:02 +00:00
Virgil
d3443d4be9 chore(gui): add AX usage examples
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 20:31:56 +00:00
Virgil
0204540b20 fix(window): fallback title for wails windows
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 20:26:27 +00:00
Virgil
29dc0d9877 refactor(gui): make theme override declarative
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 20:22:47 +00:00
Virgil
bca53679f1 ui: add global search to shell
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 20:16:54 +00:00
Virgil
dd9e8da619 refactor(gui): centralize API origin handling
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 20:12:35 +00:00
Virgil
b50149af5d feat(display): support prompt dialogs via webview fallback
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 20:08:42 +00:00
Virgil
a23e265cc6 fix(window): normalize layout state before applying geometry
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 20:04:12 +00:00
Virgil
3cf69533bf Refine GUI shell styling
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 19:59:29 +00:00
Virgil
0423f3058d chore(gui): add AX usage examples
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 19:54:47 +00:00
Virgil
81503d0968 chore(gui): align AX naming and docs
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 19:50:55 +00:00
Virgil
8db26398af Expand display websocket window bridge
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 19:45:31 +00:00
Virgil
f2eb9f03c4 feat(gui): wire shell routes and provider previews
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 19:41:35 +00:00
Virgil
fdff5435c2 refactor(gui): tighten display AX docs
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 19:35:03 +00:00
Virgil
cf8091e7e7 Add display layout helper wrappers
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 19:32:00 +00:00
Virgil
b8ddd2650b docs(display): add AX usage examples
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 19:28:48 +00:00
Virgil
0d2ae6c299 Refactor MCP layout queries
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 19:24:59 +00:00
Virgil
cf284e9954 feat(gui): add event info and layout query fixes
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 19:21:28 +00:00
Virgil
3d7998a9ca feat(systray): wire tray window attachment
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 19:16:59 +00:00
Virgil
856bb89022 docs(gui): align public API comments with AX
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 19:12:40 +00:00
Virgil
cad4e212c4 feat(gui): add missing MCP aliases and webview errors
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 19:09:10 +00:00
Virgil
483c408497 Implement tray close-desktop action
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 19:03:11 +00:00
Virgil
4f03fc4c64 Implement systray panel window handling
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Waiting to run
2026-04-02 18:59:54 +00:00
Virgil
4f7236a8bb feat(gui): add compatibility aliases for spec names
Some checks failed
Test / test (push) Waiting to run
Security Scan / security (push) Failing after 14m34s
Co-authored-by: Virgil <virgil@lethean.io>
2026-04-02 14:54:14 +00:00
Virgil
54d77d85cd fix(window): use detected screen size for tiling
Some checks failed
Security Scan / security (push) Failing after 35s
Test / test (push) Successful in 1m4s
2026-04-02 14:47:40 +00:00
Virgil
4f4a4eb8e4 feat(window): add window opacity support
Some checks failed
Test / test (push) Waiting to run
Security Scan / security (push) Failing after 35s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 14:42:03 +00:00
Virgil
45fa6942f7 feat(window): expose visibility in window lists
Some checks failed
Security Scan / security (push) Failing after 29s
Test / test (push) Successful in 1m18s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 14:36:08 +00:00
Virgil
77e03060ac feat(window): expose visibility and minimized state
Some checks failed
Security Scan / security (push) Failing after 31s
Test / test (push) Successful in 1m28s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 14:30:42 +00:00
Virgil
81b71ff50b refactor(display): delegate window mutations through IPC
Some checks failed
Security Scan / security (push) Failing after 35s
Test / test (push) Successful in 1m22s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 14:25:25 +00:00
Virgil
61ddae80f4 feat(ui): replace placeholder shell with live dashboard
Some checks failed
Security Scan / security (push) Failing after 33s
Test / test (push) Successful in 1m23s
2026-04-02 14:20:45 +00:00
Virgil
c3361b7064 refactor(gui): align gui services with ax guidance
Some checks failed
Security Scan / security (push) Failing after 36s
Test / test (push) Successful in 1m15s
2026-04-02 14:13:58 +00:00
Virgil
973217ae54 feat(gui): bridge arrange-pair and find-space
Some checks failed
Security Scan / security (push) Failing after 42s
Test / test (push) Successful in 1m34s
2026-04-02 14:08:32 +00:00
Virgil
a07fa49c20 feat(gui): add missing window mutators and MCP tools
Some checks failed
Security Scan / security (push) Failing after 35s
Test / test (push) Successful in 1m34s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 14:03:29 +00:00
Virgil
57fb567a68 feat(gui): add webview element screenshots
Some checks are pending
Security Scan / security (push) Waiting to run
Test / test (push) Successful in 1m36s
2026-04-02 13:55:56 +00:00
Virgil
a4c696ec01 Implement display service spec wrappers
Some checks failed
Security Scan / security (push) Failing after 20s
Test / test (push) Successful in 1m28s
2026-04-02 13:48:27 +00:00
Virgil
573eb5216a feat(systray): wire tray mutations and submenus
Some checks failed
Security Scan / security (push) Failing after 29s
Test / test (push) Successful in 1m22s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 13:39:36 +00:00
Virgil
a0cad39fbb feat(gui): add webview diagnostics and tray fallback
Some checks failed
Security Scan / security (push) Failing after 22s
Test / test (push) Successful in 1m25s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 13:29:46 +00:00
Virgil
3413b64f6c Expose layout stack and workflow actions 2026-04-02 13:23:17 +00:00
Virgil
3c5c109c3a feat(display): bridge missing GUI features
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 13:17:20 +00:00
Virgil
a1fbcdf6ed feat(window): restore config and screen-aware layouts
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 13:09:54 +00:00
Virgil
5653bfcc8d feat(mcp): implement missing GUI features
Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-02 13:03:55 +00:00
124 changed files with 8925 additions and 3277 deletions

View file

@ -46,7 +46,7 @@ All Wails application APIs are abstracted behind interfaces in `interfaces.go` (
| `window.go` | `WindowOption` functional options pattern, `Window` type alias for `application.WebviewWindowOptions` | | `window.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

View file

@ -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.

View file

@ -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
},
}
```

View file

@ -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

View file

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

View file

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

View file

@ -0,0 +1 @@
placeholder

14
go.mod
View file

@ -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
View file

@ -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=

View file

@ -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{

View file

@ -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
} }

View file

@ -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"`
}

View file

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

View file

@ -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
} }

View file

@ -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)
}

View file

@ -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"`

View file

@ -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
} }
} }

View file

@ -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
} }

View file

@ -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) {

View file

@ -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 }

View file

@ -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.

View file

@ -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

View file

@ -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"},
}, },

View file

@ -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

View file

@ -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
npm run build
``` ```
## Declarative Windows 3. **Run the development server:**
```bash
go run ./cmd/demo-cli serve
```
This will start the Go backend and serve the Angular custom element.
Windows are created from a `window.Window` spec instead of a fluent option chain. ## Building the Custom Element
Use `OpenWindow(window.Window{})` for the default app window, or `CreateWindow(window.Window{Name: "editor", Title: "Editor", URL: "/#/editor"})` when you need a named window and the returned `WindowInfo`. To build the Angular custom element, run the following command:
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. ```bash
cd ui
npm run build
```
This will create a single JavaScript file in the `dist` directory that you can use in any HTML page.
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
## License
This project is licensed under the EUPL-1.2 License - see the [LICENSE](LICENSE) file for details.

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -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`.

View file

@ -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 ./...
``` ```

View file

@ -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.

View file

@ -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
} }

View file

@ -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)
} }

View file

@ -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"`
}

View file

@ -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{

View file

@ -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:

View file

@ -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"`

View file

@ -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()
}

View file

@ -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)
}

View file

@ -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"`
} }

View file

@ -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
} }
} }

View file

@ -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
} }

View file

@ -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) {

View file

@ -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{

View file

@ -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
} }

View file

@ -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) {

View file

@ -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}
} }

View file

@ -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)
} }

View file

@ -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
} }

View file

@ -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
} }

View file

@ -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)
} }

View file

@ -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
} }

View file

@ -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)
} }

View file

@ -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)
} }

View file

@ -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)
} }

View file

@ -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)
} }

View file

@ -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)
} }

View file

@ -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
} }

View file

@ -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 }

View file

@ -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{}

View file

@ -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 (

View file

@ -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{

View file

@ -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
} }

View file

@ -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 }

View file

@ -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
}

View file

@ -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 := &notification.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"},
}, },

View file

@ -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)
}

View file

@ -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 }

View file

@ -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"`

View file

@ -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
} }

View file

@ -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...),
} }
} }

View file

@ -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 }

View file

@ -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{} }

View file

@ -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

View file

@ -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()

View file

@ -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{

View file

@ -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
} }

View file

@ -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)
}

View file

@ -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
} }

View file

@ -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])
}

View file

@ -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
View 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
View 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)
}

View file

@ -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"
}

View file

@ -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)

View file

@ -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)

View file

@ -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()
} }

View file

@ -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"`
}

View file

@ -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)
}
}

View file

@ -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
View 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 }
}

View file

@ -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)
}

View file

@ -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

View file

@ -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{

View file

@ -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
func (s *Service) taskSetSize(name string, width, height int) error {
platformWindow, err := s.requireWindow(name, "window.Service.taskSetSize")
if err != nil {
return err
} }
platformWindow.SetSize(width, height) if height == 0 {
height = fallbackHeight
}
}
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
} }

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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()
} }

View file

@ -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
} }

View file

@ -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() } if ww.w != nil {
func (windowHandle *wailsWindow) IsVisible() bool { return windowHandle.w.IsVisible() } return ww.w.Name()
func (windowHandle *wailsWindow) IsFocused() bool { return windowHandle.w.IsFocused() } }
func (windowHandle *wailsWindow) SetTitle(title string) { return ""
windowHandle.title = title
windowHandle.w.SetTitle(title)
} }
func (windowHandle *wailsWindow) SetBounds(x, y, width, height int) { func (ww *wailsWindow) Position() (int, int) { return ww.w.Position() }
windowHandle.w.SetPosition(x, y) func (ww *wailsWindow) Size() (int, int) { return ww.w.Size() }
windowHandle.w.SetSize(width, height) 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 (windowHandle *wailsWindow) SetPosition(x, y int) { windowHandle.w.SetPosition(x, y) } func (ww *wailsWindow) SetOpacity(opacity float32) { ww.w.SetOpacity(opacity) }
func (windowHandle *wailsWindow) SetSize(width, height int) { windowHandle.w.SetSize(width, height) } func (ww *wailsWindow) SetVisibility(visible bool) {
func (windowHandle *wailsWindow) SetBackgroundColour(r, g, b, a uint8) {
windowHandle.w.SetBackgroundColour(application.NewRGBA(r, g, b, a))
}
func (windowHandle *wailsWindow) SetVisibility(visible bool) {
if visible { 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 := ""

View file

@ -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