Compare commits

...

55 commits
v0.1.0 ... 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
Snider
05a865d8f6 Merge remote-tracking branch 'github/dev'
Some checks failed
Security Scan / security (push) Failing after 38s
Test / test (push) Failing after 1m43s
2026-03-22 00:57:51 +00:00
Snider
97cc7b04ec chore: sync dependencies for v0.1.5
Some checks failed
Security Scan / security (push) Failing after 27s
Test / test (push) Failing after 2m5s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 17:53:39 +00:00
Snider
0038250839 chore: sync dependencies for v0.1.4
Some checks failed
Security Scan / security (push) Failing after 31s
Test / test (push) Failing after 2m24s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-17 17:48:57 +00:00
Snider
29d92601cb chore: sync dependencies for v0.1.3
Some checks failed
Security Scan / security (push) Failing after 34s
Test / test (push) Failing after 2m8s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-16 22:18:48 +00:00
Snider
fad16c8c76 chore: sync workspace dependencies
Some checks failed
Security Scan / security (push) Failing after 34s
Test / test (push) Failing after 1m44s
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-15 15:44:56 +00:00
Snider
cfc25a40b8 chore: sync go.mod dependencies
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-03-15 15:37:18 +00:00
Snider
5b57906a08 chore: add .core/ and .idea/ to .gitignore
Some checks failed
Security Scan / security (push) Failing after 8s
Test / test (push) Failing after 1m55s
2026-03-15 10:17:50 +00:00
Snider
9c737548a0 fix: update stale import paths and dependency versions from extraction
Some checks failed
Security Scan / security (push) Failing after 9s
Test / test (push) Failing after 2m16s
Resolve stale forge.lthn.ai/core/cli v0.1.0 references (tag never existed,
earliest is v0.0.1) and regenerate go.sum via workspace-aware go mod tidy.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-14 13:38:59 +00:00
Snider
d9f831013a chore: update transitive dependency versions
Co-Authored-By: Virgil <virgil@lethean.io>
2026-02-21 19:54:35 +00:00
13474 changed files with 2115160 additions and 750 deletions

1
.gitignore vendored
View file

@ -22,3 +22,4 @@ Thumbs.db
# Environment
.env
.env.local
.core/

View file

@ -73,6 +73,7 @@ Response:
| `window_maximize` | Maximize window |
| `window_minimize` | Minimize window |
| `window_focus` | Bring window to front |
| `window_title_set` | Alias for `window_title` |
### WebView Interaction
@ -84,6 +85,7 @@ Response:
| `webview_screenshot` | Capture page |
| `webview_navigate` | Navigate to URL |
| `webview_console` | Get console messages |
| `webview_errors` | Get structured JavaScript errors |
### Screen Management
@ -93,6 +95,8 @@ Response:
| `screen_primary` | Get primary screen |
| `screen_at_point` | Get screen at coordinates |
| `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

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

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

View file

@ -0,0 +1 @@
placeholder

61
go.mod
View file

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

191
go.sum
View file

@ -1,137 +1,69 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
forge.lthn.ai/core/go v0.2.2 h1:JCWaFfiG+agb0f7b5DO1g+h40x6nb4UydxJ7D+oZk5k=
forge.lthn.ai/core/go v0.2.2/go.mod h1:gE6c8h+PJ2287qNhVUJ5SOe1kopEwHEquvinstpuyJc=
forge.lthn.ai/core/go-io v0.0.5 h1:oSyngKTkB1gR5fEWYKXftTg9FxwnpddSiCq2dlwfImE=
forge.lthn.ai/core/go-io v0.0.5/go.mod h1:ZlU9OQpsvNFNmTJoaHbFIkisZyc0eCq0p8znVWQLRf0=
forge.lthn.ai/core/go-log v0.0.1 h1:x/E6EfF9vixzqiLHQOl2KT25HyBcMc9qiBkomqVlpPg=
forge.lthn.ai/core/go-log v0.0.1/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
forge.lthn.ai/core/go-webview v0.1.2 h1:KC5AlkAg1SkmeUVsW4ZeEyrNxy+cZLaHu6SPwWLBi1A=
forge.lthn.ai/core/go-webview v0.1.2/go.mod h1:AcIN8cASb7N9c/G5VMmFMIXcPvf6Kk7HcbOQxfiylQ8=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
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/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/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/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/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw=
forge.lthn.ai/core/go-webview v0.1.7 h1:9+aEHeAvNcPX8Zwr+UGu0/T+menRm5T1YOmqZ9dawDc=
forge.lthn.ai/core/go-webview v0.1.7/go.mod h1:5n1tECD1wBV/uFZRY9ZjfPFO5TYZrlaR3mQFwvO2nek=
github.com/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/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/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/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM=
github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y=
github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ=
github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/kevinburke/ssh_config v1.4.0 h1:6xxtP5bZ2E4NF5tuQulISpTO2z8XbtH8cg1PWkxoFkQ=
github.com/kevinburke/ssh_config v1.4.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w=
github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc=
github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/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/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/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/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
github.com/samber/lo v1.52.0 h1:Rvi+3BFHES3A8meP33VPAxiBZX/Aws5RxrschYGjomw=
github.com/samber/lo v1.52.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0=
github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc=
github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0=
github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
github.com/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/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
@ -140,57 +72,32 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/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/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0=
github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/wails/v3 v3.0.0-alpha.74 h1:wRm1EiDQtxDisXk46NtpiBH90STwfKp36NrTDwOEdxw=
github.com/wailsapp/wails/v3 v3.0.0-alpha.74/go.mod h1:4saK4A4K9970X+X7RkMwP2lyGbLogcUz54wVeq4C/V8=
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
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/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/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
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

@ -9,3 +9,16 @@ type TaskSetText struct{ Text string }
// TaskClear clears the clipboard. Result: bool (success)
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
package clipboard
import "encoding/base64"
// Platform abstracts the system clipboard backend.
type Platform interface {
Text() (string, bool)
@ -12,3 +14,22 @@ type ClipboardContent struct {
Text string `json:"text"`
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"
)
// Options holds configuration for the clipboard service.
// Options configures the clipboard service.
// Use: core.WithService(clipboard.Register(platform))
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 {
*core.ServiceRuntime[Options]
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) {
return func(c *core.Core) (any, error) {
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 {
s.Core().RegisterQuery(s.handleQuery)
s.Core().RegisterTask(s.handleTask)
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 {
return nil
}
@ -43,6 +47,12 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
case QueryText:
text, ok := s.platform.Text()
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:
return nil, false, nil
}
@ -53,7 +63,17 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
case TaskSetText:
return s.platform.SetText(t.Text), true, nil
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:
return nil, false, nil
}

View file

@ -11,8 +11,10 @@ import (
)
type mockPlatform struct {
text string
ok bool
text string
ok bool
img []byte
imgOk bool
}
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 != ""
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) {
t.Helper()
@ -79,3 +87,34 @@ func TestTaskClear_Good(t *testing.T) {
assert.Equal(t, "", r.(ClipboardContent).Text)
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

@ -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_always_on_top` - Pin window above others
- [x] `window_background_colour` - Set window background color with alpha (transparency)
- [x] `window_opacity` - Set window opacity
- [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
- [x] `layout_tile` - Auto-tile windows (left/right/top/bottom/quadrants/grid)
- [x] `layout_stack` - Stack windows in cascade pattern
- [ ] `layout_beside_editor` - Position window beside detected IDE window
- [ ] `layout_suggest` - Given screen dimensions, suggest optimal arrangement
- [x] `layout_beside_editor` - Position window beside detected IDE window
- [x] `layout_suggest` - Given screen dimensions, suggest optimal arrangement
- [x] `layout_snap` - Snap window to screen edge/corner/center
### AI-Optimized Layout
- [ ] `screen_find_space` - Find empty screen space for new window
- [ ] `window_arrange_pair` - Put two windows side-by-side optimally
- [x] `screen_find_space` - Find empty screen space for new window
- [x] `window_arrange_pair` - Put two windows side-by-side optimally
- [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)
### DevTools
- [ ] `webview_devtools_open` - Open DevTools for window
- [ ] `webview_devtools_close` - Close DevTools
- [x] `webview_devtools_open` - Open DevTools for window
- [x] `webview_devtools_close` - Close DevTools
---
@ -124,8 +125,8 @@ This document tracks the implementation of display server features that enable A
### Clipboard
- [x] `clipboard_read` - Read clipboard text content
- [x] `clipboard_write` - Write text to clipboard
- [ ] `clipboard_read_image` - Read image from clipboard
- [ ] `clipboard_write_image` - Write image to clipboard
- [x] `clipboard_read_image` - Read image from clipboard
- [x] `clipboard_write_image` - Write image to clipboard
- [x] `clipboard_has` - Check clipboard content type
- [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_permission_request` - Request notification permission
- [x] `notification_permission_check` - Check notification authorization status
- [ ] `notification_clear` - Clear notifications
- [ ] `notification_with_actions` - Interactive notifications with buttons
- [x] `notification_clear` - Clear notifications
- [x] `notification_with_actions` - Interactive notifications with buttons
### Dialogs
- [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_message` - Show message dialog (info/warning/error) (via notification_show)
- [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
- [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_on_change` - Subscribe to theme changes (via WebSocket events)
@ -173,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_menu` - Set tray menu items (with nested submenus)
- [x] `tray_info` - Get tray status info
- [ ] `tray_show_message` - Show tray balloon notification
- [x] `tray_show_message` - Show tray balloon notification
---
@ -235,7 +236,6 @@ This document tracks the implementation of display server features that enable A
- [x] `tray_info` - Get tray status
### Phase 8 - Remaining Features (Future)
- [ ] window_opacity (true opacity if Wails adds support)
- [ ] layout_beside_editor, layout_suggest
- [ ] webview_devtools_open, webview_devtools_close
- [ ] clipboard_read_image, clipboard_write_image

File diff suppressed because it is too large Load diff

View file

@ -2,18 +2,167 @@ package display
import (
"context"
"encoding/base64"
"os"
"path/filepath"
"testing"
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/gui/pkg/clipboard"
"forge.lthn.ai/core/gui/pkg/dialog"
"forge.lthn.ai/core/gui/pkg/environment"
"forge.lthn.ai/core/gui/pkg/menu"
"forge.lthn.ai/core/gui/pkg/notification"
"forge.lthn.ai/core/gui/pkg/screen"
"forge.lthn.ai/core/gui/pkg/systray"
"forge.lthn.ai/core/gui/pkg/webview"
"forge.lthn.ai/core/gui/pkg/window"
"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
}
type mockClipboardPlatform struct {
text string
ok bool
image []byte
imgOk bool
}
func (m *mockClipboardPlatform) Text() (string, bool) { return m.text, m.ok }
func (m *mockClipboardPlatform) SetText(text string) bool {
m.text = text
m.ok = text != ""
return true
}
func (m *mockClipboardPlatform) Image() ([]byte, bool) { return m.image, m.imgOk }
func (m *mockClipboardPlatform) SetImage(data []byte) bool {
m.image = data
m.imgOk = len(data) > 0
return true
}
type mockNotificationPlatform struct {
permGranted bool
sendCalled bool
clearCalled bool
lastOpts notification.NotificationOptions
}
func (m *mockNotificationPlatform) Send(opts notification.NotificationOptions) error {
m.sendCalled = true
m.lastOpts = opts
return nil
}
func (m *mockNotificationPlatform) SendWithActions(opts notification.NotificationOptions) error {
m.sendCalled = true
m.lastOpts = opts
return nil
}
func (m *mockNotificationPlatform) RequestPermission() (bool, error) { return m.permGranted, nil }
func (m *mockNotificationPlatform) CheckPermission() (bool, error) { return m.permGranted, nil }
func (m *mockNotificationPlatform) Clear() error {
m.clearCalled = true
return nil
}
type mockEnvironmentPlatform struct {
isDark bool
info environment.EnvironmentInfo
accent string
}
func (m *mockEnvironmentPlatform) IsDarkMode() bool { return m.isDark }
func (m *mockEnvironmentPlatform) Info() environment.EnvironmentInfo {
return m.info
}
func (m *mockEnvironmentPlatform) AccentColour() string { return m.accent }
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 mockDialogPlatform struct {
button string
openFilePaths []string
saveFilePath string
openDirPath string
last dialog.MessageDialogOptions
}
func (m *mockDialogPlatform) OpenFile(opts dialog.OpenFileOptions) ([]string, error) {
if len(m.openFilePaths) == 0 {
return []string{"/tmp/file.txt"}, nil
}
return m.openFilePaths, nil
}
func (m *mockDialogPlatform) SaveFile(opts dialog.SaveFileOptions) (string, error) {
if m.saveFilePath == "" {
return "/tmp/save.txt", nil
}
return m.saveFilePath, nil
}
func (m *mockDialogPlatform) OpenDirectory(opts dialog.OpenDirectoryOptions) (string, error) {
if m.openDirPath == "" {
return "/tmp/dir", nil
}
return m.openDirPath, nil
}
func (m *mockDialogPlatform) MessageDialog(opts dialog.MessageDialogOptions) (string, error) {
m.last = opts
if m.button != "" {
return m.button, nil
}
return "OK", nil
}
type mockWebviewConnector struct{}
func (m *mockWebviewConnector) Navigate(url string) error { return nil }
func (m *mockWebviewConnector) Click(selector string) error { return nil }
func (m *mockWebviewConnector) Type(selector, text string) error { return nil }
func (m *mockWebviewConnector) Hover(selector string) error { return nil }
func (m *mockWebviewConnector) Select(selector, value string) error { return nil }
func (m *mockWebviewConnector) Check(selector string, checked bool) error { return nil }
func (m *mockWebviewConnector) Evaluate(script string) (any, error) { return nil, nil }
func (m *mockWebviewConnector) Screenshot() ([]byte, error) { return nil, nil }
func (m *mockWebviewConnector) GetURL() (string, error) { return "", nil }
func (m *mockWebviewConnector) GetTitle() (string, error) { return "", nil }
func (m *mockWebviewConnector) GetHTML(selector string) (string, error) { return "", nil }
func (m *mockWebviewConnector) QuerySelector(selector string) (*webview.ElementInfo, error) {
return nil, nil
}
func (m *mockWebviewConnector) QuerySelectorAll(selector string) ([]*webview.ElementInfo, error) {
return nil, nil
}
func (m *mockWebviewConnector) GetConsole() []webview.ConsoleMessage { return nil }
func (m *mockWebviewConnector) ClearConsole() {}
func (m *mockWebviewConnector) SetViewport(width, height int) error { return nil }
func (m *mockWebviewConnector) UploadFile(selector string, paths []string) error {
return nil
}
func (m *mockWebviewConnector) Close() error { return nil }
// --- Test helpers ---
// newTestDisplayService creates a display service registered with Core for IPC testing.
@ -35,6 +184,14 @@ func newTestConclave(t *testing.T) *core.Core {
c, err := core.New(
core.WithService(Register(nil)),
core.WithService(window.Register(window.NewMockPlatform())),
core.WithService(screen.Register(&mockScreenPlatform{
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.WithService(systray.Register(systray.NewMockPlatform())),
core.WithService(menu.Register(menu.NewMockPlatform())),
core.WithServiceLock(),
@ -44,6 +201,65 @@ func newTestConclave(t *testing.T) *core.Core {
return c
}
func newExtendedTestConclave(t *testing.T) *core.Core {
t.Helper()
fixture := newExtendedTestConclaveWithMocks(t)
return fixture.core
}
type extendedTestConclave struct {
core *core.Core
clipboardPlatform *mockClipboardPlatform
notificationPlatform *mockNotificationPlatform
dialogPlatform *mockDialogPlatform
environmentPlatform *mockEnvironmentPlatform
}
func newExtendedTestConclaveWithMocks(t *testing.T) *extendedTestConclave {
t.Helper()
clipboardPlatform := &mockClipboardPlatform{text: "hello", ok: true, image: []byte{1, 2, 3}, imgOk: true}
notificationPlatform := &mockNotificationPlatform{permGranted: true}
dialogPlatform := &mockDialogPlatform{button: "OK"}
environmentPlatform := &mockEnvironmentPlatform{
isDark: true,
accent: "rgb(0,122,255)",
info: environment.EnvironmentInfo{
OS: "darwin", Arch: "arm64",
Platform: environment.PlatformInfo{Name: "macOS", Version: "14.0"},
},
}
c, err := core.New(
core.WithService(Register(nil)),
core.WithService(window.Register(window.NewMockPlatform())),
core.WithService(screen.Register(&mockScreenPlatform{
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.WithService(systray.Register(systray.NewMockPlatform())),
core.WithService(menu.Register(menu.NewMockPlatform())),
core.WithService(clipboard.Register(clipboardPlatform)),
core.WithService(notification.Register(notificationPlatform)),
core.WithService(dialog.Register(dialogPlatform)),
core.WithService(environment.Register(environmentPlatform)),
core.WithService(webview.Register(webview.Options{})),
core.WithServiceLock(),
)
require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
return &extendedTestConclave{
core: c,
clipboardPlatform: clipboardPlatform,
notificationPlatform: notificationPlatform,
dialogPlatform: dialogPlatform,
environmentPlatform: environmentPlatform,
}
}
// --- Tests ---
func TestNew(t *testing.T) {
@ -210,6 +426,8 @@ func TestGetWindowInfo_Good(t *testing.T) {
assert.Equal(t, 200, info.Y)
assert.Equal(t, 800, info.Width)
assert.Equal(t, 600, info.Height)
assert.True(t, info.Visible)
assert.False(t, info.Minimized)
}
func TestGetWindowInfo_Bad(t *testing.T) {
@ -222,15 +440,45 @@ func TestGetWindowInfo_Bad(t *testing.T) {
assert.Nil(t, info)
}
func TestTileWindows_UsesPrimaryScreenSize(t *testing.T) {
c := newTestConclave(t)
svc := core.MustServiceFor[*Service](c, "display")
_ = svc.OpenWindow(window.WithName("left"))
_ = svc.OpenWindow(window.WithName("right"))
err := svc.TileWindows(window.TileModeLeftRight, []string{"left", "right"})
require.NoError(t, err)
left, err := svc.GetWindowInfo("left")
require.NoError(t, err)
assert.Equal(t, 1280, left.Width)
right, err := svc.GetWindowInfo("right")
require.NoError(t, err)
assert.Equal(t, 1280, right.Width)
}
func TestListWindowInfos_Good(t *testing.T) {
c := newTestConclave(t)
svc := core.MustServiceFor[*Service](c, "display")
_ = svc.OpenWindow(window.WithName("win-1"))
_ = svc.OpenWindow(window.WithName("win-2"))
_ = svc.MinimizeWindow("win-2")
infos := svc.ListWindowInfos()
assert.Len(t, infos, 2)
byName := make(map[string]window.WindowInfo, len(infos))
for _, info := range infos {
byName[info.Name] = info
}
assert.True(t, byName["win-1"].Visible)
assert.False(t, byName["win-1"].Minimized)
assert.False(t, byName["win-2"].Visible)
assert.True(t, byName["win-2"].Minimized)
}
func TestSetWindowPosition_Good(t *testing.T) {
@ -324,9 +572,15 @@ func TestSetWindowVisibility_Good(t *testing.T) {
err := svc.SetWindowVisibility("vis-win", false)
assert.NoError(t, err)
info, err := svc.GetWindowInfo("vis-win")
require.NoError(t, err)
assert.False(t, info.Visible)
err = svc.SetWindowVisibility("vis-win", true)
assert.NoError(t, err)
info, err = svc.GetWindowInfo("vis-win")
require.NoError(t, err)
assert.True(t, info.Visible)
}
func TestSetWindowAlwaysOnTop_Good(t *testing.T) {
@ -407,6 +661,174 @@ func TestGetSavedWindowStates_Good(t *testing.T) {
assert.NotNil(t, states)
}
func TestServiceWrappers_Good(t *testing.T) {
fixture := newExtendedTestConclaveWithMocks(t)
svc := core.MustServiceFor[*Service](fixture.core, "display")
t.Run("screen wrappers", func(t *testing.T) {
screens := svc.GetScreens()
require.Len(t, screens, 1)
primary, err := svc.GetPrimaryScreen()
require.NoError(t, err)
require.NotNil(t, primary)
assert.True(t, primary.IsPrimary)
atPoint, err := svc.GetScreenAtPoint(10, 10)
require.NoError(t, err)
require.NotNil(t, atPoint)
workAreas := svc.GetWorkAreas()
require.Len(t, workAreas, 1)
})
t.Run("window-screen lookup", func(t *testing.T) {
require.NoError(t, svc.OpenWindow(window.WithName("screen-win"), window.WithSize(640, 480)))
screenInfo, err := svc.GetScreenForWindow("screen-win")
require.NoError(t, err)
require.NotNil(t, screenInfo)
})
t.Run("layout helpers", func(t *testing.T) {
suggestion, err := svc.SuggestLayout(2, 2560, 1440)
require.NoError(t, err)
assert.Equal(t, "side-by-side", suggestion.Mode)
require.NoError(t, svc.OpenWindow(window.WithName("editor-beside"), window.WithSize(800, 600)))
require.NoError(t, svc.OpenWindow(window.WithName("assistant-beside"), window.WithSize(400, 600)))
require.NoError(t, svc.BesideEditor("editor-beside", "assistant-beside"))
editorInfo, err := svc.GetWindowInfo("editor-beside")
require.NoError(t, err)
require.NotNil(t, editorInfo)
assert.Equal(t, 0, editorInfo.X)
assert.Equal(t, 1792, editorInfo.Width)
assistantInfo, err := svc.GetWindowInfo("assistant-beside")
require.NoError(t, err)
require.NotNil(t, assistantInfo)
assert.Equal(t, 1792, assistantInfo.X)
assert.Equal(t, 768, assistantInfo.Width)
})
t.Run("clipboard wrappers", func(t *testing.T) {
text, err := svc.ReadClipboard()
require.NoError(t, err)
assert.Equal(t, "hello", text)
assert.True(t, svc.HasClipboard())
require.NoError(t, svc.WriteClipboard("updated"))
text, err = svc.ReadClipboard()
require.NoError(t, err)
assert.Equal(t, "updated", text)
image, err := svc.ReadClipboardImage()
require.NoError(t, err)
assert.True(t, image.HasContent)
require.NoError(t, svc.WriteClipboardImage([]byte{9, 8, 7}))
require.NoError(t, svc.ClearClipboard())
assert.False(t, svc.HasClipboard())
})
t.Run("notification wrappers", func(t *testing.T) {
require.NoError(t, svc.ShowInfoNotification("Info", "Hello"))
require.True(t, fixture.notificationPlatform.sendCalled)
assert.Equal(t, notification.SeverityInfo, fixture.notificationPlatform.lastOpts.Severity)
granted, err := svc.RequestNotificationPermission()
require.NoError(t, err)
assert.True(t, granted)
granted, err = svc.CheckNotificationPermission()
require.NoError(t, err)
assert.True(t, granted)
require.NoError(t, svc.ClearNotifications())
assert.True(t, fixture.notificationPlatform.clearCalled)
})
t.Run("compatibility aliases", func(t *testing.T) {
_ = svc.OpenWindow(window.WithName("alias-win"))
require.NoError(t, svc.FocusSet("alias-win"))
info, err := svc.GetWindowInfo("alias-win")
require.NoError(t, err)
require.NotNil(t, info)
assert.True(t, info.Focused)
require.NoError(t, svc.DialogMessage("warning", "Alias", "Message"))
assert.Equal(t, notification.SeverityWarning, fixture.notificationPlatform.lastOpts.Severity)
assert.Equal(t, "Alias", fixture.notificationPlatform.lastOpts.Title)
assert.Equal(t, "Message", fixture.notificationPlatform.lastOpts.Message)
})
t.Run("dialog wrappers", func(t *testing.T) {
paths, err := svc.OpenFileDialog(dialog.OpenFileOptions{Title: "Pick"})
require.NoError(t, err)
require.NotEmpty(t, paths)
path, err := svc.OpenSingleFileDialog(dialog.OpenFileOptions{Title: "Pick"})
require.NoError(t, err)
assert.Equal(t, paths[0], path)
path, err = svc.SaveFileDialog(dialog.SaveFileOptions{Filename: "out.txt"})
require.NoError(t, err)
assert.Equal(t, "/tmp/save.txt", path)
path, err = svc.OpenDirectoryDialog(dialog.OpenDirectoryOptions{Title: "Pick Dir"})
require.NoError(t, err)
assert.Equal(t, "/tmp/dir", path)
confirmed, err := svc.ConfirmDialog("Confirm", "Continue?")
require.NoError(t, err)
assert.True(t, confirmed)
button, accepted, err := svc.PromptDialog("Question", "Continue?")
require.NoError(t, err)
assert.Equal(t, "OK", button)
assert.True(t, accepted)
})
t.Run("theme wrappers", func(t *testing.T) {
theme := svc.GetTheme()
require.NotNil(t, theme)
assert.True(t, theme.IsDark)
assert.Equal(t, "dark", svc.GetSystemTheme())
require.NoError(t, svc.SetTheme(false))
assert.False(t, fixture.environmentPlatform.isDark)
theme = svc.GetTheme()
require.NotNil(t, theme)
assert.False(t, theme.IsDark)
assert.Equal(t, "light", svc.GetSystemTheme())
})
t.Run("tray wrappers", func(t *testing.T) {
info := svc.GetTrayInfo()
require.NotNil(t, info)
assert.True(t, info["active"].(bool))
require.NoError(t, svc.SetTrayTooltip("Updated Tooltip"))
require.NoError(t, svc.SetTrayLabel("Updated Label"))
require.NoError(t, svc.SetTrayIcon([]byte{1, 2, 3}))
require.NoError(t, svc.SetTrayMenu([]systray.TrayMenuItem{
{Label: "One", ActionID: "one"},
{Type: "separator"},
{Label: "More", Submenu: []systray.TrayMenuItem{{Label: "Two", ActionID: "two"}}},
}))
info = svc.GetTrayInfo()
require.NotNil(t, info)
assert.Equal(t, "Updated Tooltip", info["tooltip"])
assert.Equal(t, "Updated Label", info["label"])
assert.True(t, info["hasIcon"].(bool))
items, ok := info["menuItems"].([]systray.TrayMenuItem)
require.True(t, ok)
require.Len(t, items, 3)
})
}
func TestHandleIPCEvents_WindowOpened_Good(t *testing.T) {
c := newTestConclave(t)
@ -503,3 +925,320 @@ func TestHandleConfigTask_Persists_Good(t *testing.T) {
require.NoError(t, err)
assert.Contains(t, string(data), "default_width")
}
func TestHandleWSMessage_Extended_Good(t *testing.T) {
c := newExtendedTestConclave(t)
svc := core.MustServiceFor[*Service](c, "display")
_ = svc.OpenWindow(
window.WithName("editor"),
window.WithTitle("Editor"),
window.WithSize(1200, 800),
)
_ = svc.OpenWindow(
window.WithName("assistant"),
window.WithTitle("Assistant"),
window.WithSize(900, 800),
)
t.Run("layout suggest", func(t *testing.T) {
result, handled, err := svc.handleWSMessage(WSMessage{Action: "layout:suggest"})
require.NoError(t, err)
assert.True(t, handled)
suggestion, ok := result.(window.LayoutSuggestion)
require.True(t, ok)
assert.Equal(t, "side-by-side", suggestion.Mode)
})
t.Run("window list", func(t *testing.T) {
result, handled, err := svc.handleWSMessage(WSMessage{Action: "window:list"})
require.NoError(t, err)
assert.True(t, handled)
windows, ok := result.([]window.WindowInfo)
require.True(t, ok)
assert.GreaterOrEqual(t, len(windows), 2)
})
t.Run("window get", func(t *testing.T) {
result, handled, err := svc.handleWSMessage(WSMessage{
Action: "window:get",
Data: map[string]any{"name": "editor"},
})
require.NoError(t, err)
assert.True(t, handled)
info, ok := result.(*window.WindowInfo)
require.True(t, ok)
require.NotNil(t, info)
assert.Equal(t, "editor", info.Name)
})
t.Run("window focused", func(t *testing.T) {
require.NoError(t, svc.FocusWindow("assistant"))
result, handled, err := svc.handleWSMessage(WSMessage{Action: "window:focused"})
require.NoError(t, err)
assert.True(t, handled)
assert.Equal(t, "assistant", result)
})
t.Run("window title get", func(t *testing.T) {
result, handled, err := svc.handleWSMessage(WSMessage{
Action: "window:title-get",
Data: map[string]any{"name": "editor"},
})
require.NoError(t, err)
assert.True(t, handled)
assert.Equal(t, "Editor", result)
})
t.Run("window position and bounds", func(t *testing.T) {
_, handled, err := svc.handleWSMessage(WSMessage{
Action: "window:position",
Data: map[string]any{
"name": "assistant",
"x": float64(40),
"y": float64(50),
},
})
require.NoError(t, err)
assert.True(t, handled)
_, handled, err = svc.handleWSMessage(WSMessage{
Action: "window:bounds",
Data: map[string]any{
"name": "assistant",
"x": float64(60),
"y": float64(70),
"width": float64(800),
"height": float64(640),
},
})
require.NoError(t, err)
assert.True(t, handled)
info, err := svc.GetWindowInfo("assistant")
require.NoError(t, err)
require.NotNil(t, info)
assert.Equal(t, 60, info.X)
assert.Equal(t, 70, info.Y)
assert.Equal(t, 800, info.Width)
assert.Equal(t, 640, info.Height)
})
t.Run("window create and close", func(t *testing.T) {
result, handled, err := svc.handleWSMessage(WSMessage{
Action: "window:create",
Data: map[string]any{
"name": "ws-new",
"title": "WS New",
"url": "/ws-new",
"width": float64(500),
"height": float64(350),
},
})
require.NoError(t, err)
assert.True(t, handled)
created, ok := result.(*window.WindowInfo)
require.True(t, ok)
require.NotNil(t, created)
assert.Equal(t, "ws-new", created.Name)
_, handled, err = svc.handleWSMessage(WSMessage{
Action: "window:close",
Data: map[string]any{"name": "ws-new"},
})
require.NoError(t, err)
assert.True(t, handled)
})
t.Run("layout stack", func(t *testing.T) {
_, handled, err := svc.handleWSMessage(WSMessage{
Action: "layout:stack",
Data: map[string]any{
"windows": []any{"editor", "assistant"},
"offsetX": 25,
"offsetY": 30,
},
})
require.NoError(t, err)
assert.True(t, handled)
})
t.Run("layout workflow", func(t *testing.T) {
_, handled, err := svc.handleWSMessage(WSMessage{
Action: "layout:workflow",
Data: map[string]any{
"workflow": "coding",
"windows": []any{"editor", "assistant"},
},
})
require.NoError(t, err)
assert.True(t, handled)
})
t.Run("window arrange pair", func(t *testing.T) {
_, handled, err := svc.handleWSMessage(WSMessage{
Action: "window:arrange-pair",
Data: map[string]any{
"first": "editor",
"second": "assistant",
},
})
require.NoError(t, err)
assert.True(t, handled)
})
t.Run("screen find space", func(t *testing.T) {
result, handled, err := svc.handleWSMessage(WSMessage{
Action: "screen:find-space",
Data: map[string]any{
"width": float64(400),
"height": float64(300),
},
})
require.NoError(t, err)
assert.True(t, handled)
space, ok := result.(window.SpaceInfo)
require.True(t, ok)
assert.Equal(t, 2560, space.ScreenWidth)
assert.Equal(t, 1440, space.ScreenHeight)
assert.Equal(t, 400, space.Width)
assert.Equal(t, 300, space.Height)
})
t.Run("clipboard image read", func(t *testing.T) {
result, handled, err := svc.handleWSMessage(WSMessage{Action: "clipboard:read-image"})
require.NoError(t, err)
assert.True(t, handled)
content, ok := result.(clipboard.ClipboardImageContent)
require.True(t, ok)
assert.True(t, content.HasContent)
assert.NotEmpty(t, content.Base64)
})
t.Run("clipboard image write", func(t *testing.T) {
payload := base64.StdEncoding.EncodeToString([]byte{9, 8, 7})
_, handled, err := svc.handleWSMessage(WSMessage{
Action: "clipboard:write-image",
Data: map[string]any{"data": payload},
})
require.NoError(t, err)
assert.True(t, handled)
})
t.Run("notification actions", func(t *testing.T) {
_, handled, err := svc.handleWSMessage(WSMessage{
Action: "notification:with-actions",
Data: map[string]any{
"title": "Heads up",
"message": "Choose one",
"actions": []any{
map[string]any{"id": "ok", "label": "OK"},
},
},
})
require.NoError(t, err)
assert.True(t, handled)
})
t.Run("notification clear", func(t *testing.T) {
_, handled, err := svc.handleWSMessage(WSMessage{Action: "notification:clear"})
require.NoError(t, err)
assert.True(t, handled)
})
t.Run("theme set", func(t *testing.T) {
_, handled, err := svc.handleWSMessage(WSMessage{
Action: "theme:set",
Data: map[string]any{"theme": "light"},
})
require.NoError(t, err)
assert.True(t, handled)
theme := svc.GetTheme()
require.NotNil(t, theme)
assert.False(t, theme.IsDark)
})
t.Run("webview devtools", func(t *testing.T) {
_, handled, err := svc.handleWSMessage(WSMessage{
Action: "webview:devtools-open",
Data: map[string]any{"window": "editor"},
})
require.NoError(t, err)
assert.True(t, handled)
_, handled, err = svc.handleWSMessage(WSMessage{
Action: "webview:devtools-close",
Data: map[string]any{"window": "editor"},
})
require.NoError(t, err)
assert.True(t, handled)
})
t.Run("webview errors", func(t *testing.T) {
result, handled, err := svc.handleWSMessage(WSMessage{
Action: "webview:errors",
Data: map[string]any{"window": "editor", "limit": float64(10)},
})
require.NoError(t, err)
assert.True(t, handled)
errors, ok := result.([]webview.ExceptionInfo)
require.True(t, ok)
assert.Len(t, errors, 0)
})
t.Run("tray message", func(t *testing.T) {
_, handled, err := svc.handleWSMessage(WSMessage{
Action: "tray:show-message",
Data: map[string]any{"title": "Core", "message": "Ready"},
})
require.NoError(t, err)
assert.True(t, handled)
})
t.Run("tray close desktop", func(t *testing.T) {
svc.handleTrayAction("close-desktop")
for _, info := range svc.ListWindowInfos() {
assert.False(t, info.Visible, "window should be hidden after close-desktop")
}
})
t.Run("tray tooltip", func(t *testing.T) {
_, handled, err := svc.handleWSMessage(WSMessage{
Action: "tray:set-tooltip",
Data: map[string]any{"tooltip": "Updated"},
})
require.NoError(t, err)
assert.True(t, handled)
})
t.Run("tray label", func(t *testing.T) {
_, handled, err := svc.handleWSMessage(WSMessage{
Action: "tray:set-label",
Data: map[string]any{"label": "Updated"},
})
require.NoError(t, err)
assert.True(t, handled)
})
t.Run("prompt dialog", func(t *testing.T) {
result, handled, err := svc.handleWSMessage(WSMessage{
Action: "dialog:prompt",
Data: map[string]any{"title": "Question", "message": "Continue?"},
})
require.NoError(t, err)
assert.True(t, handled)
assert.Equal(t, "OK", result)
})
t.Run("event info", func(t *testing.T) {
result, handled, err := svc.handleWSMessage(WSMessage{Action: "event:info"})
require.NoError(t, err)
assert.True(t, handled)
info, ok := result.(EventServerInfo)
require.True(t, ok)
assert.Equal(t, 0, info.ConnectedClients)
assert.Equal(t, 0, info.Subscriptions)
})
}

View file

@ -1,3 +1,4 @@
// pkg/display/events.go
package display
import (
@ -12,15 +13,16 @@ import (
)
// EventType represents the type of event.
// Use: eventType := display.EventWindowFocus
type EventType string
const (
EventWindowFocus EventType = "window.focus"
EventWindowBlur EventType = "window.blur"
EventWindowMove EventType = "window.move"
EventWindowResize EventType = "window.resize"
EventWindowClose EventType = "window.close"
EventWindowCreate EventType = "window.create"
EventWindowFocus EventType = "window.focus"
EventWindowBlur EventType = "window.blur"
EventWindowMove EventType = "window.move"
EventWindowResize EventType = "window.resize"
EventWindowClose EventType = "window.close"
EventWindowCreate EventType = "window.create"
EventThemeChange EventType = "theme.change"
EventScreenChange EventType = "screen.change"
EventNotificationClick EventType = "notification.click"
@ -43,6 +45,7 @@ const (
)
// Event represents a display event sent to subscribers.
// Use: evt := display.Event{Type: display.EventWindowFocus, Window: "editor"}
type Event struct {
Type EventType `json:"type"`
Timestamp int64 `json:"timestamp"`
@ -51,12 +54,22 @@ type Event struct {
}
// Subscription represents a client subscription to events.
// Use: sub := display.Subscription{ID: "sub-1", EventTypes: []display.EventType{display.EventWindowFocus}}
type Subscription struct {
ID string `json:"id"`
EventTypes []EventType `json:"eventTypes"`
}
// EventServerInfo summarises the live WebSocket event server state.
// 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
clients map[*websocket.Conn]*clientState
@ -66,12 +79,14 @@ type WSEventManager struct {
}
// clientState tracks a client's subscriptions.
// Use: state := &clientState{subscriptions: map[string]*Subscription{}}
type clientState struct {
subscriptions map[string]*Subscription
mu sync.RWMutex
}
// NewWSEventManager creates a new event manager.
// Use: events := display.NewWSEventManager()
func NewWSEventManager() *WSEventManager {
em := &WSEventManager{
upgrader: websocket.Upgrader{
@ -305,6 +320,23 @@ func (em *WSEventManager) ConnectedClients() int {
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.
func (em *WSEventManager) Close() {
em.mu.Lock()

View file

@ -3,15 +3,16 @@ package display
import "github.com/wailsapp/wails/v3/pkg/application"
// App abstracts the Wails application for the orchestrator.
// After Spec D cleanup, only Quit() and Logger() remain —
// all other Wails Manager APIs are accessed via IPC.
// App abstracts the Wails application for the display orchestrator.
// The service uses Logger() for diagnostics and Quit() for shutdown.
// Use: var app display.App
type App interface {
Logger() Logger
Quit()
}
// Logger wraps Wails logging.
// Use: var logger display.Logger
type Logger interface {
Info(message string, args ...any)
}

View file

@ -4,9 +4,17 @@ package display
// ActionIDECommand is broadcast when a menu handler triggers an IDE command
// (save, run, build). Replaces direct s.app.Event().Emit("ide:*") calls.
// Listeners (e.g. editor windows) handle this via HandleIPCEvents.
// Use: _ = c.ACTION(display.ActionIDECommand{Command: "save"})
type ActionIDECommand struct {
Command string `json:"command"` // "save", "run", "build"
}
// EventIDECommand is the WS event type for IDE commands.
// Use: eventType := display.EventIDECommand
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

@ -16,6 +16,13 @@ type TaskOpenFileManager struct {
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.
type ActionThemeChanged struct {
IsDark bool `json:"isDark"`

View file

@ -3,6 +3,7 @@ package environment
import (
"context"
"fmt"
"forge.lthn.ai/core/go/pkg/core"
)
@ -13,8 +14,9 @@ type Options struct{}
// Service is a core.Service providing environment queries and theme change events via IPC.
type Service struct {
*core.ServiceRuntime[Options]
platform Platform
cancelTheme func() // cancel function for theme change listener
platform Platform
cancelTheme func() // cancel function for theme change listener
overrideDark *bool
}
// 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) {
switch q.(type) {
case QueryTheme:
isDark := s.platform.IsDarkMode()
isDark := s.currentTheme()
theme := "light"
if isDark {
theme = "dark"
@ -74,7 +76,52 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
switch t := t.(type) {
case TaskOpenFileManager:
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:
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,12 +17,14 @@ type mockPlatform struct {
accentColour string
openFMErr error
themeHandler func(isDark bool)
setThemeSeen bool
setThemeDark bool
mu sync.Mutex
}
func (m *mockPlatform) IsDarkMode() bool { return m.isDark }
func (m *mockPlatform) Info() EnvironmentInfo { return m.info }
func (m *mockPlatform) AccentColour() string { return m.accentColour }
func (m *mockPlatform) IsDarkMode() bool { return m.isDark }
func (m *mockPlatform) Info() EnvironmentInfo { return m.info }
func (m *mockPlatform) AccentColour() string { return m.accentColour }
func (m *mockPlatform) OpenFileManager(path string, selectFile bool) error {
return m.openFMErr
}
@ -36,6 +38,12 @@ func (m *mockPlatform) OnThemeChange(handler func(isDark bool)) func() {
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).
func (m *mockPlatform) simulateThemeChange(isDark bool) {
@ -131,3 +139,33 @@ func TestThemeChange_ActionBroadcast_Good(t *testing.T) {
require.NotNil(t, r)
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

@ -7,6 +7,12 @@ import (
"forge.lthn.ai/core/go/pkg/core"
"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"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -34,7 +40,57 @@ type mockClipPlatform struct {
}
func (m *mockClipPlatform) Text() (string, bool) { return m.text, m.ok }
func (m *mockClipPlatform) SetText(t string) bool { m.text = t; m.ok = t != ""; return true }
func (m *mockClipPlatform) SetText(t string) bool { m.text = t; m.ok = t != ""; return true }
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) {
c, err := core.New(
@ -53,6 +109,171 @@ func TestMCP_Good_ClipboardRoundTrip(t *testing.T) {
assert.Equal(t, "hello", content.Text)
}
func TestMCP_Good_DialogMessage(t *testing.T) {
mock := &mockNotificationPlatform{}
c, err := core.New(
core.WithService(notification.Register(mock)),
core.WithServiceLock(),
)
require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
sub := New(c)
_, result, err := sub.dialogMessage(context.Background(), nil, DialogMessageInput{
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) {
c, _ := core.New(core.WithServiceLock())
// Without any services, QUERY should return handled=false

View file

@ -87,6 +87,42 @@ func (s *Subsystem) clipboardClear(_ context.Context, _ *mcp.CallToolRequest, _
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 ---
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_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_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

@ -47,9 +47,35 @@ func (s *Subsystem) themeSystem(_ context.Context, _ *mcp.CallToolRequest, _ The
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 ---
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_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

@ -5,6 +5,8 @@ import (
"context"
"fmt"
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/gui/pkg/screen"
"forge.lthn.ai/core/gui/pkg/window"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -136,6 +138,176 @@ func (s *Subsystem) layoutSnap(_ context.Context, _ *mcp.CallToolRequest, input
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 ---
type LayoutStackInput struct {
Windows []string `json:"windows,omitempty"`
OffsetX int `json:"offsetX,omitempty"`
OffsetY int `json:"offsetY,omitempty"`
}
type LayoutStackOutput struct {
Success bool `json:"success"`
}
func (s *Subsystem) layoutStack(_ context.Context, _ *mcp.CallToolRequest, input LayoutStackInput) (*mcp.CallToolResult, LayoutStackOutput, error) {
_, _, err := s.core.PERFORM(window.TaskStackWindows{
Windows: input.Windows,
OffsetX: input.OffsetX,
OffsetY: input.OffsetY,
})
if err != nil {
return nil, LayoutStackOutput{}, err
}
return nil, LayoutStackOutput{Success: true}, nil
}
// --- layout_workflow ---
type LayoutWorkflowInput struct {
Workflow string `json:"workflow"`
Windows []string `json:"windows,omitempty"`
}
type LayoutWorkflowOutput struct {
Success bool `json:"success"`
}
func (s *Subsystem) layoutWorkflow(_ context.Context, _ *mcp.CallToolRequest, input LayoutWorkflowInput) (*mcp.CallToolResult, LayoutWorkflowOutput, error) {
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 {
return nil, LayoutWorkflowOutput{}, err
}
return nil, LayoutWorkflowOutput{Success: true}, nil
}
// --- Registration ---
func (s *Subsystem) registerLayoutTools(server *mcp.Server) {
@ -146,4 +318,28 @@ func (s *Subsystem) registerLayoutTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{Name: "layout_get", Description: "Get a specific layout by name"}, s.layoutGet)
mcp.AddTool(server, &mcp.Tool{Name: "layout_tile", Description: "Tile windows in a grid arrangement"}, s.layoutTile)
mcp.AddTool(server, &mcp.Tool{Name: "layout_snap", Description: "Snap a window to a screen edge or corner"}, s.layoutSnap)
mcp.AddTool(server, &mcp.Tool{Name: "layout_beside_editor", Description: "Place a window beside a detected editor window"}, s.layoutBesideEditor)
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

@ -32,6 +32,31 @@ func (s *Subsystem) notificationShow(_ context.Context, _ *mcp.CallToolRequest,
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 ---
type NotificationPermissionRequestInput struct{}
@ -70,10 +95,60 @@ func (s *Subsystem) notificationPermissionCheck(_ context.Context, _ *mcp.CallTo
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 ---
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_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_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

@ -5,6 +5,8 @@ import (
"context"
"fmt"
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/gui/pkg/display"
"forge.lthn.ai/core/gui/pkg/screen"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
@ -109,6 +111,33 @@ func (s *Subsystem) screenWorkAreas(_ context.Context, _ *mcp.CallToolRequest, _
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 ---
type ScreenForWindowInput struct {
Window string `json:"window"`
}
type ScreenForWindowOutput struct {
Screen *screen.Screen `json:"screen"`
}
func (s *Subsystem) screenForWindow(_ context.Context, _ *mcp.CallToolRequest, input ScreenForWindowInput) (*mcp.CallToolResult, ScreenForWindowOutput, error) {
svc, err := core.ServiceFor[*display.Service](s.core, "display")
if err != nil {
return nil, ScreenForWindowOutput{}, err
}
scr, err := svc.GetScreenForWindow(input.Window)
if err != nil {
return nil, ScreenForWindowOutput{}, err
}
return nil, ScreenForWindowOutput{Screen: scr}, nil
}
// --- Registration ---
func (s *Subsystem) registerScreenTools(server *mcp.Server) {
@ -117,4 +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_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_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)
}

View file

@ -36,8 +36,10 @@ type TraySetTooltipOutput struct {
}
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
_ = input.Tooltip
_, _, err := s.core.PERFORM(systray.TaskSetTooltip{Tooltip: input.Tooltip})
if err != nil {
return nil, TraySetTooltipOutput{}, err
}
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) {
// Label is part of the tray configuration; placeholder for now
_ = input.Label
_, _, err := s.core.PERFORM(systray.TaskSetLabel{Label: input.Label})
if err != nil {
return nil, TraySetLabelOutput{}, err
}
return nil, TraySetLabelOutput{Success: true}, nil
}
@ -75,6 +79,24 @@ func (s *Subsystem) trayInfo(_ context.Context, _ *mcp.CallToolRequest, _ TrayIn
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 ---
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_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_show_message", Description: "Show a tray message or notification"}, s.trayShowMessage)
}

View file

@ -110,6 +110,30 @@ func (s *Subsystem) webviewScreenshot(_ context.Context, _ *mcp.CallToolRequest,
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 ---
type WebviewScrollInput struct {
@ -271,6 +295,63 @@ func (s *Subsystem) webviewConsoleClear(_ context.Context, _ *mcp.CallToolReques
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 ---
type WebviewQueryInput struct {
@ -294,6 +375,12 @@ func (s *Subsystem) webviewQuery(_ context.Context, _ *mcp.CallToolRequest, inpu
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 ---
type WebviewQueryAllInput struct {
@ -340,6 +427,199 @@ func (s *Subsystem) webviewDOMTree(_ context.Context, _ *mcp.CallToolRequest, in
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 ---
type WebviewURLInput struct {
@ -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_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_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_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)
@ -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_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_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_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_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_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

@ -89,22 +89,17 @@ type WindowCreateOutput struct {
}
func (s *Subsystem) windowCreate(_ context.Context, _ *mcp.CallToolRequest, input WindowCreateInput) (*mcp.CallToolResult, WindowCreateOutput, error) {
opts := []window.WindowOption{
window.WithName(input.Name),
}
if input.Title != "" {
opts = append(opts, window.WithTitle(input.Title))
}
if input.URL != "" {
opts = append(opts, window.WithURL(input.URL))
}
if input.Width > 0 || input.Height > 0 {
opts = append(opts, window.WithSize(input.Width, input.Height))
}
if input.X != 0 || input.Y != 0 {
opts = append(opts, window.WithPosition(input.X, input.Y))
}
result, _, err := s.core.PERFORM(window.TaskOpenWindow{Opts: opts})
result, _, err := s.core.PERFORM(window.TaskOpenWindow{
Window: &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 {
return nil, WindowCreateOutput{}, err
}
@ -163,7 +158,7 @@ type WindowSizeOutput struct {
}
func (s *Subsystem) windowSize(_ context.Context, _ *mcp.CallToolRequest, input WindowSizeInput) (*mcp.CallToolResult, WindowSizeOutput, error) {
_, _, err := s.core.PERFORM(window.TaskSetSize{Name: input.Name, W: input.Width, H: input.Height})
_, _, err := s.core.PERFORM(window.TaskSetSize{Name: input.Name, Width: input.Width, Height: input.Height})
if err != nil {
return nil, WindowSizeOutput{}, err
}
@ -188,7 +183,7 @@ func (s *Subsystem) windowBounds(_ context.Context, _ *mcp.CallToolRequest, inpu
if err != nil {
return nil, WindowBoundsOutput{}, err
}
_, _, err = s.core.PERFORM(window.TaskSetSize{Name: input.Name, W: input.Width, H: input.Height})
_, _, err = s.core.PERFORM(window.TaskSetSize{Name: input.Name, Width: input.Width, Height: input.Height})
if err != nil {
return nil, WindowBoundsOutput{}, err
}
@ -281,6 +276,33 @@ func (s *Subsystem) windowTitle(_ context.Context, _ *mcp.CallToolRequest, input
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 ---
type WindowTitleGetInput struct {
Name string `json:"name"`
}
type WindowTitleGetOutput struct {
Title string `json:"title"`
}
func (s *Subsystem) windowTitleGet(_ context.Context, _ *mcp.CallToolRequest, input WindowTitleGetInput) (*mcp.CallToolResult, WindowTitleGetOutput, error) {
result, _, err := s.core.QUERY(window.QueryWindowByName{Name: input.Name})
if err != nil {
return nil, WindowTitleGetOutput{}, err
}
info, _ := result.(*window.WindowInfo)
if info == nil {
return nil, WindowTitleGetOutput{}, nil
}
return nil, WindowTitleGetOutput{Title: info.Title}, nil
}
// --- window_visibility ---
type WindowVisibilityInput struct {
@ -299,6 +321,65 @@ func (s *Subsystem) windowVisibility(_ context.Context, _ *mcp.CallToolRequest,
return nil, WindowVisibilityOutput{Success: true}, nil
}
// --- window_always_on_top ---
type WindowAlwaysOnTopInput struct {
Name string `json:"name"`
AlwaysOnTop bool `json:"alwaysOnTop"`
}
type WindowAlwaysOnTopOutput struct {
Success bool `json:"success"`
}
func (s *Subsystem) windowAlwaysOnTop(_ context.Context, _ *mcp.CallToolRequest, input WindowAlwaysOnTopInput) (*mcp.CallToolResult, WindowAlwaysOnTopOutput, error) {
_, _, err := s.core.PERFORM(window.TaskSetAlwaysOnTop{Name: input.Name, AlwaysOnTop: input.AlwaysOnTop})
if err != nil {
return nil, WindowAlwaysOnTopOutput{}, err
}
return nil, WindowAlwaysOnTopOutput{Success: true}, nil
}
// --- window_background_colour ---
type WindowBackgroundColourInput struct {
Name string `json:"name"`
Red uint8 `json:"red"`
Green uint8 `json:"green"`
Blue uint8 `json:"blue"`
Alpha uint8 `json:"alpha"`
}
type WindowBackgroundColourOutput struct {
Success bool `json:"success"`
}
func (s *Subsystem) windowBackgroundColour(_ context.Context, _ *mcp.CallToolRequest, input WindowBackgroundColourInput) (*mcp.CallToolResult, WindowBackgroundColourOutput, error) {
_, _, err := s.core.PERFORM(window.TaskSetBackgroundColour{
Name: input.Name, Red: input.Red, Green: input.Green, Blue: input.Blue, Alpha: input.Alpha,
})
if err != nil {
return nil, WindowBackgroundColourOutput{}, err
}
return nil, WindowBackgroundColourOutput{Success: true}, nil
}
// --- window_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 ---
type WindowFullscreenInput struct {
@ -332,7 +413,13 @@ func (s *Subsystem) registerWindowTools(server *mcp.Server) {
mcp.AddTool(server, &mcp.Tool{Name: "window_minimize", Description: "Minimise a window"}, s.windowMinimize)
mcp.AddTool(server, &mcp.Tool{Name: "window_restore", Description: "Restore a maximised or minimised window"}, s.windowRestore)
mcp.AddTool(server, &mcp.Tool{Name: "window_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_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_visibility", Description: "Show or hide a window"}, s.windowVisibility)
mcp.AddTool(server, &mcp.Tool{Name: "window_always_on_top", Description: "Pin a window above others"}, s.windowAlwaysOnTop)
mcp.AddTool(server, &mcp.Tool{Name: "window_background_colour", Description: "Set a window background colour"}, s.windowBackgroundColour)
mcp.AddTool(server, &mcp.Tool{Name: "window_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)
}

View file

@ -2,6 +2,7 @@
package menu
// MenuItem describes a menu item for construction (structure only — no handlers).
// Use: item := menu.MenuItem{Label: "Quit", OnClick: func() {}}
type MenuItem struct {
Label string
Accelerator string
@ -15,16 +16,19 @@ type MenuItem struct {
}
// Manager builds application menus via a Platform backend.
// Use: mgr := menu.NewManager(platform)
type Manager struct {
platform Platform
}
// NewManager creates a menu Manager.
// Use: mgr := menu.NewManager(platform)
func NewManager(platform Platform) *Manager {
return &Manager{platform: platform}
}
// Build constructs a PlatformMenu from a tree of MenuItems.
// Use: built := mgr.Build([]menu.MenuItem{{Label: "File"}})
func (m *Manager) Build(items []MenuItem) PlatformMenu {
menu := m.platform.NewMenu()
m.buildItems(menu, items)
@ -60,12 +64,14 @@ func (m *Manager) buildItems(menu PlatformMenu, items []MenuItem) {
}
// SetApplicationMenu builds and sets the application menu.
// Use: mgr.SetApplicationMenu([]menu.MenuItem{{Label: "Quit"}})
func (m *Manager) SetApplicationMenu(items []MenuItem) {
menu := m.Build(items)
m.platform.SetApplicationMenu(menu)
}
// Platform returns the underlying platform.
// Use: backend := mgr.Platform()
func (m *Manager) Platform() Platform {
return m.platform
}

View file

@ -1,3 +1,4 @@
// pkg/menu/messages.go
package menu
// QueryConfig requests this service's config section from the display orchestrator.

View file

@ -1,3 +1,4 @@
// pkg/menu/mock_platform.go
package menu
// MockPlatform is an exported mock for cross-package integration tests.
@ -5,15 +6,19 @@ type MockPlatform struct{}
func NewMockPlatform() *MockPlatform { return &MockPlatform{} }
func (m *MockPlatform) NewMenu() PlatformMenu { return &exportedMockPlatformMenu{} }
func (m *MockPlatform) NewMenu() PlatformMenu { return &exportedMockPlatformMenu{} }
func (m *MockPlatform) SetApplicationMenu(menu PlatformMenu) {}
type exportedMockPlatformMenu struct{}
func (m *exportedMockPlatformMenu) Add(label string) PlatformMenuItem { return &exportedMockPlatformMenuItem{} }
func (m *exportedMockPlatformMenu) AddSeparator() {}
func (m *exportedMockPlatformMenu) AddSubmenu(label string) PlatformMenu { return &exportedMockPlatformMenu{} }
func (m *exportedMockPlatformMenu) AddRole(role MenuRole) {}
func (m *exportedMockPlatformMenu) Add(label string) PlatformMenuItem {
return &exportedMockPlatformMenuItem{}
}
func (m *exportedMockPlatformMenu) AddSeparator() {}
func (m *exportedMockPlatformMenu) AddSubmenu(label string) PlatformMenu {
return &exportedMockPlatformMenu{}
}
func (m *exportedMockPlatformMenu) AddRole(role MenuRole) {}
type exportedMockPlatformMenuItem struct{}

View file

@ -2,12 +2,14 @@
package menu
// Platform abstracts the menu backend.
// Use: var platform menu.Platform = backend
type Platform interface {
NewMenu() PlatformMenu
SetApplicationMenu(menu PlatformMenu)
}
// PlatformMenu is a live menu handle.
// Use: var root menu.PlatformMenu = platform.NewMenu()
type PlatformMenu interface {
Add(label string) PlatformMenuItem
AddSeparator()
@ -17,6 +19,7 @@ type PlatformMenu interface {
}
// PlatformMenuItem is a single menu item.
// Use: var item menu.PlatformMenuItem = root.Add("Quit")
type PlatformMenuItem interface {
SetAccelerator(accel string) PlatformMenuItem
SetTooltip(text string) PlatformMenuItem
@ -26,6 +29,7 @@ type PlatformMenuItem interface {
}
// MenuRole is a predefined platform menu role.
// Use: role := menu.RoleFileMenu
type MenuRole int
const (

View file

@ -1,3 +1,4 @@
// pkg/menu/register.go
package menu
import "forge.lthn.ai/core/go/pkg/core"

View file

@ -1,3 +1,4 @@
// pkg/menu/service.go
package menu
import (

View file

@ -10,5 +10,8 @@ type TaskSend struct{ Opts NotificationOptions }
// TaskRequestPermission requests notification authorisation. Result: bool (granted)
type TaskRequestPermission struct{}
// ActionNotificationClicked is broadcast when a notification is clicked (future).
// 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 }

View file

@ -8,6 +8,12 @@ type Platform interface {
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.
type NotificationSeverity int
@ -24,9 +30,18 @@ type NotificationOptions struct {
Message string `json:"message"`
Subtitle string `json:"subtitle,omitempty"`
Severity NotificationSeverity `json:"severity,omitempty"`
Actions []NotificationAction `json:"actions,omitempty"`
}
// PermissionStatus indicates whether notifications are authorised.
type PermissionStatus struct {
Granted bool `json:"granted"`
}
type clearer interface {
Clear() error
}
type actionSender interface {
SendWithActions(opts NotificationOptions) error
}

View file

@ -10,16 +10,19 @@ import (
"forge.lthn.ai/core/gui/pkg/dialog"
)
// Options holds configuration for the notification service.
// Options configures the notification service.
// Use: core.WithService(notification.Register(platform))
type Options struct{}
// Service is a core.Service managing notifications via IPC.
// Service manages notifications via Core tasks and queries.
// Use: svc := &notification.Service{}
type Service struct {
*core.ServiceRuntime[Options]
platform Platform
}
// Register creates a factory closure that captures the Platform adapter.
// 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) {
return func(c *core.Core) (any, error) {
return &Service{
@ -29,14 +32,15 @@ func Register(p Platform) func(*core.Core) (any, error) {
}
}
// OnStartup registers IPC handlers.
// OnStartup registers notification handlers with Core.
// Use: _ = svc.OnStartup(context.Background())
func (s *Service) OnStartup(ctx context.Context) error {
s.Core().RegisterQuery(s.handleQuery)
s.Core().RegisterTask(s.handleTask)
return nil
}
// HandleIPCEvents is auto-discovered by core.WithService.
// HandleIPCEvents satisfies Core's IPC hook.
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}
@ -54,32 +58,45 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
switch t := t.(type) {
case TaskSend:
return nil, true, s.send(t.Opts)
return nil, true, s.sendNotification(t.Opts)
case TaskRequestPermission:
granted, err := s.platform.RequestPermission()
return granted, true, err
case TaskClear:
if clr, ok := s.platform.(clearer); ok {
return nil, true, clr.Clear()
}
return nil, true, nil
default:
return nil, false, nil
}
}
// send attempts native notification, falls back to dialog via IPC.
func (s *Service) send(opts NotificationOptions) error {
// Generate ID if not provided
// sendNotification attempts a native notification and falls back to a dialog via IPC.
func (s *Service) sendNotification(opts NotificationOptions) error {
// Generate an ID when the caller does not provide one.
if opts.ID == "" {
opts.ID = fmt.Sprintf("core-%d", time.Now().UnixNano())
}
if len(opts.Actions) > 0 {
if sender, ok := s.platform.(actionSender); ok {
if err := sender.SendWithActions(opts); err == nil {
return nil
}
}
}
if err := s.platform.Send(opts); err != nil {
// Fallback: show as dialog via IPC
return s.fallbackDialog(opts)
// Fall back to a dialog when the native notification fails.
return s.showFallbackDialog(opts)
}
return nil
}
// fallbackDialog shows a dialog via IPC when native notifications fail.
func (s *Service) fallbackDialog(opts NotificationOptions) error {
// Map severity to dialog type
// showFallbackDialog shows a dialog via IPC when native notifications fail.
func (s *Service) showFallbackDialog(opts NotificationOptions) error {
// Map severity to dialog type.
var dt dialog.DialogType
switch opts.Severity {
case SeverityWarning:

View file

@ -18,6 +18,7 @@ type mockPlatform struct {
permErr error
lastOpts NotificationOptions
sendCalled bool
clearCalled bool
}
func (m *mockPlatform) Send(opts NotificationOptions) error {
@ -25,8 +26,14 @@ func (m *mockPlatform) Send(opts NotificationOptions) error {
m.lastOpts = opts
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) 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).
type mockDialogPlatform struct {
@ -117,3 +124,26 @@ func TestTaskSend_Bad(t *testing.T) {
_, handled, _ := c.PERFORM(TaskSend{})
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
// QueryAll returns all screens. Result: []Screen
// Use: result, _, err := c.QUERY(screen.QueryAll{})
type QueryAll struct{}
// QueryPrimary returns the primary screen. Result: *Screen (nil if not found)
// Use: result, _, err := c.QUERY(screen.QueryPrimary{})
type QueryPrimary struct{}
// 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 }
// 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 }
// QueryWorkAreas returns work areas for all screens. Result: []Rect
// Use: result, _, err := c.QUERY(screen.QueryWorkAreas{})
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 }

View file

@ -2,12 +2,14 @@
package screen
// Platform abstracts the screen/display backend.
// Use: var p screen.Platform
type Platform interface {
GetAll() []Screen
GetPrimary() *Screen
}
// Screen describes a display/monitor.
// Use: scr := screen.Screen{ID: "display-1"}
type Screen struct {
ID string `json:"id"`
Name string `json:"name"`
@ -20,6 +22,7 @@ type Screen struct {
}
// Rect represents a rectangle with position and dimensions.
// Use: rect := screen.Rect{X: 0, Y: 0, Width: 1920, Height: 1080}
type Rect struct {
X int `json:"x"`
Y int `json:"y"`
@ -28,6 +31,7 @@ type Rect struct {
}
// Size represents dimensions.
// Use: size := screen.Size{Width: 1920, Height: 1080}
type Size struct {
Width int `json:"width"`
Height int `json:"height"`

View file

@ -8,15 +8,18 @@ import (
)
// Options holds configuration for the screen service.
// Use: svc, err := screen.Register(platform)(core.New())
type Options struct{}
// Service is a core.Service providing screen/display queries via IPC.
// Use: svc, err := screen.Register(platform)(core.New())
type Service struct {
*core.ServiceRuntime[Options]
platform Platform
}
// 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) {
return func(c *core.Core) (any, error) {
return &Service{
@ -27,12 +30,14 @@ func Register(p Platform) func(*core.Core) (any, error) {
}
// OnStartup registers IPC handlers.
// Use: _ = svc.OnStartup(context.Background())
func (s *Service) OnStartup(ctx context.Context) error {
s.Core().RegisterQuery(s.handleQuery)
return nil
}
// HandleIPCEvents is auto-discovered by core.WithService.
// Use: _ = svc.HandleIPCEvents(core, msg)
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}

View file

@ -1,13 +1,15 @@
// pkg/systray/menu.go
package systray
import "fmt"
import "forge.lthn.ai/core/go/pkg/core"
// 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 {
if m.tray == nil {
return fmt.Errorf("tray not initialised")
return core.E("systray.SetMenu", "tray not initialised", nil)
}
m.menuItems = append([]TrayMenuItem(nil), items...)
menu := m.buildMenu(items)
m.tray.SetMenu(menu)
return nil
@ -16,16 +18,19 @@ func (m *Manager) SetMenu(items []TrayMenuItem) error {
// buildMenu recursively builds a PlatformMenu from TrayMenuItem descriptors.
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 {
if item.Type == "separator" {
menu.AddSeparator()
continue
}
if len(item.Submenu) > 0 {
sub := m.buildMenu(item.Submenu)
mi := menu.Add(item.Label)
_ = mi.AddSubmenu()
_ = sub // TODO: wire sub into parent via platform
sub := menu.AddSubmenu(item.Label)
m.buildMenuInto(sub, item.Submenu)
continue
}
mi := menu.Add(item.Label)
@ -47,10 +52,10 @@ func (m *Manager) buildMenu(items []TrayMenuItem) PlatformMenu {
})
}
}
return menu
}
// RegisterCallback registers a callback for a menu action ID.
// Use: m.RegisterCallback("quit", func() { _ = app.Quit() })
func (m *Manager) RegisterCallback(actionID string, callback func()) {
m.mu.Lock()
m.callbacks[actionID] = callback
@ -58,6 +63,7 @@ func (m *Manager) RegisterCallback(actionID string, callback func()) {
}
// UnregisterCallback removes a callback.
// Use: m.UnregisterCallback("quit")
func (m *Manager) UnregisterCallback(actionID string) {
m.mu.Lock()
delete(m.callbacks, actionID)
@ -65,6 +71,7 @@ func (m *Manager) UnregisterCallback(actionID string) {
}
// GetCallback returns the callback for an action ID.
// Use: callback, ok := m.GetCallback("quit")
func (m *Manager) GetCallback(actionID string) (func(), bool) {
m.mu.RLock()
defer m.mu.RUnlock()
@ -73,8 +80,14 @@ func (m *Manager) GetCallback(actionID string) (func(), bool) {
}
// GetInfo returns tray status information.
// Use: info := m.GetInfo()
func (m *Manager) GetInfo() 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,30 +1,54 @@
// pkg/systray/messages.go
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{}
// --- Tasks ---
// TaskSetTrayIcon sets the tray icon.
// Use: _, _, err := c.PERFORM(systray.TaskSetTrayIcon{Data: iconBytes})
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 }
// TaskShowPanel shows the tray panel window.
// Use: _, _, err := c.PERFORM(systray.TaskShowPanel{})
type TaskShowPanel struct{}
// TaskHidePanel hides the tray panel window.
// Use: _, _, err := c.PERFORM(systray.TaskHidePanel{})
type TaskHidePanel struct{}
// 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{}
// ActionTrayMenuItemClicked is broadcast when a tray menu item is clicked.
// Use: _ = c.ACTION(systray.ActionTrayMenuItemClicked{ActionID: "quit"})
type ActionTrayMenuItemClicked struct{ ActionID string }

View file

@ -1,11 +1,20 @@
// pkg/systray/mock_platform.go
package systray
// MockPlatform is an exported mock for cross-package integration tests.
// Use: platform := systray.NewMockPlatform()
type MockPlatform struct{}
// NewMockPlatform creates a tray platform mock.
// Use: platform := systray.NewMockPlatform()
func NewMockPlatform() *MockPlatform { return &MockPlatform{} }
// NewTray creates a mock tray handle for tests.
// Use: tray := platform.NewTray()
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{} }
type exportedMockTray struct {
@ -13,14 +22,17 @@ type exportedMockTray struct {
tooltip, label string
}
func (t *exportedMockTray) SetIcon(data []byte) { t.icon = data }
func (t *exportedMockTray) SetTemplateIcon(data []byte) { t.templateIcon = data }
func (t *exportedMockTray) SetTooltip(text string) { t.tooltip = text }
func (t *exportedMockTray) SetLabel(text string) { t.label = text }
func (t *exportedMockTray) SetMenu(menu PlatformMenu) {}
func (t *exportedMockTray) AttachWindow(w WindowHandle) {}
func (t *exportedMockTray) SetIcon(data []byte) { t.icon = data }
func (t *exportedMockTray) SetTemplateIcon(data []byte) { t.templateIcon = data }
func (t *exportedMockTray) SetTooltip(text string) { t.tooltip = text }
func (t *exportedMockTray) SetLabel(text string) { t.label = text }
func (t *exportedMockTray) SetMenu(menu PlatformMenu) {}
func (t *exportedMockTray) AttachWindow(w WindowHandle) {}
type exportedMockMenu struct{ items []exportedMockMenuItem }
type exportedMockMenu struct {
items []exportedMockMenuItem
submenus []*exportedMockMenu
}
func (m *exportedMockMenu) Add(label string) PlatformMenuItem {
mi := &exportedMockMenuItem{label: label}
@ -28,15 +40,21 @@ func (m *exportedMockMenu) Add(label string) PlatformMenuItem {
return mi
}
func (m *exportedMockMenu) AddSeparator() {}
func (m *exportedMockMenu) AddSubmenu(label string) PlatformMenu {
sub := &exportedMockMenu{}
m.items = append(m.items, exportedMockMenuItem{label: label})
m.submenus = append(m.submenus, sub)
return sub
}
type exportedMockMenuItem struct {
label, tooltip string
label, tooltip string
checked, enabled bool
onClick func()
}
func (mi *exportedMockMenuItem) SetTooltip(tip string) { mi.tooltip = tip }
func (mi *exportedMockMenuItem) SetChecked(checked bool) { mi.checked = checked }
func (mi *exportedMockMenuItem) SetEnabled(enabled bool) { mi.enabled = enabled }
func (mi *exportedMockMenuItem) OnClick(fn func()) { mi.onClick = fn }
func (mi *exportedMockMenuItem) AddSubmenu() PlatformMenu { return &exportedMockMenu{} }
func (mi *exportedMockMenuItem) SetTooltip(tip string) { mi.tooltip = tip }
func (mi *exportedMockMenuItem) SetChecked(checked bool) { mi.checked = checked }
func (mi *exportedMockMenuItem) SetEnabled(enabled bool) { mi.enabled = enabled }
func (mi *exportedMockMenuItem) OnClick(fn func()) { mi.onClick = fn }
func (mi *exportedMockMenuItem) AddSubmenu() PlatformMenu { return &exportedMockMenu{} }

View file

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

View file

@ -2,12 +2,14 @@
package systray
// Platform abstracts the system tray backend.
// Use: var p systray.Platform
type Platform interface {
NewTray() PlatformTray
NewMenu() PlatformMenu // Menu factory for building tray menus
}
// PlatformTray is a live tray handle from the backend.
// Use: var tray systray.PlatformTray
type PlatformTray interface {
SetIcon(data []byte)
SetTemplateIcon(data []byte)
@ -18,12 +20,15 @@ type PlatformTray interface {
}
// PlatformMenu is a tray menu built by the backend.
// Use: var menu systray.PlatformMenu
type PlatformMenu interface {
Add(label string) PlatformMenuItem
AddSeparator()
AddSubmenu(label string) PlatformMenu
}
// PlatformMenuItem is a single item in a tray menu.
// Use: var item systray.PlatformMenuItem
type PlatformMenuItem interface {
SetTooltip(text string)
SetChecked(checked bool)
@ -35,6 +40,7 @@ type PlatformMenuItem interface {
// WindowHandle is a cross-package interface for window operations.
// Defined locally to avoid circular imports (display imports systray).
// pkg/window.PlatformWindow satisfies this implicitly.
// Use: var w systray.WindowHandle
type WindowHandle interface {
Name() string
Show()

View file

@ -1,3 +1,4 @@
// pkg/systray/register.go
package systray
import "forge.lthn.ai/core/go/pkg/core"

View file

@ -1,15 +1,19 @@
// pkg/systray/service.go
package systray
import (
"context"
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/gui/pkg/notification"
)
// Options holds configuration for the systray service.
// Options configures the systray service.
// Use: core.WithService(systray.Register(platform))
type Options struct{}
// Service is a core.Service managing the system tray via IPC.
// Service manages system tray operations via Core tasks.
// Use: svc := &systray.Service{}
type Service struct {
*core.ServiceRuntime[Options]
manager *Manager
@ -17,7 +21,8 @@ type Service struct {
iconPath string
}
// OnStartup queries config and registers IPC handlers.
// OnStartup loads tray config and registers task handlers.
// Use: _ = svc.OnStartup(context.Background())
func (s *Service) OnStartup(ctx context.Context) error {
cfg, handled, _ := s.Core().QUERY(QueryConfig{})
if handled {
@ -43,7 +48,7 @@ func (s *Service) applyConfig(cfg map[string]any) {
}
}
// HandleIPCEvents is auto-discovered and registered by core.WithService.
// HandleIPCEvents satisfies Core's IPC hook.
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}
@ -52,14 +57,18 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
switch t := t.(type) {
case TaskSetTrayIcon:
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:
return nil, true, s.taskSetTrayMenu(t)
case TaskShowPanel:
// Panel show — deferred (requires WindowHandle integration)
return nil, true, nil
return nil, true, s.manager.ShowPanel()
case TaskHidePanel:
// Panel hide — deferred (requires WindowHandle integration)
return nil, true, nil
return nil, true, s.manager.HidePanel()
case TaskShowMessage:
return nil, true, s.showTrayMessage(t.Title, t.Message)
default:
return nil, false, nil
}
@ -78,7 +87,29 @@ func (s *Service) taskSetTrayMenu(t TaskSetTrayMenu) error {
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 {
return s.manager
}

View file

@ -9,6 +9,18 @@ import (
"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) {
t.Helper()
c, err := core.New(
@ -39,6 +51,24 @@ func TestTaskSetTrayIcon_Good(t *testing.T) {
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) {
svc, c := newTestSystrayService(t)
@ -54,6 +84,33 @@ func TestTaskSetTrayMenu_Good(t *testing.T) {
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) {
// No systray service — PERFORM returns handled=false
c, err := core.New(core.WithServiceLock())
@ -61,3 +118,29 @@ func TestTaskSetTrayIcon_Bad(t *testing.T) {
_, handled, _ := c.PERFORM(TaskSetTrayIcon{Data: nil})
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

@ -3,23 +3,31 @@ package systray
import (
_ "embed"
"fmt"
"sync"
"forge.lthn.ai/core/go/pkg/core"
)
//go:embed assets/apptray.png
var defaultIcon []byte
// 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 {
platform Platform
tray PlatformTray
callbacks map[string]func()
mu sync.RWMutex
platform Platform
tray PlatformTray
panelWindow WindowHandle
callbacks map[string]func()
tooltip string
label string
hasIcon bool
hasTemplateIcon bool
menuItems []TrayMenuItem
mu sync.RWMutex
}
// NewManager creates a systray Manager.
// Use: manager := systray.NewManager(platform)
func NewManager(platform Platform) *Manager {
return &Manager{
platform: platform,
@ -28,68 +36,112 @@ func NewManager(platform Platform) *Manager {
}
// Setup creates the system tray with default icon and tooltip.
// Use: _ = manager.Setup("Core", "Core")
func (m *Manager) Setup(tooltip, label string) error {
m.tray = m.platform.NewTray()
if m.tray == nil {
return fmt.Errorf("platform returned nil tray")
return core.E("systray.Setup", "platform returned nil tray", nil)
}
m.tray.SetTemplateIcon(defaultIcon)
m.tray.SetTooltip(tooltip)
m.tray.SetLabel(label)
m.tooltip = tooltip
m.label = label
m.hasTemplateIcon = true
return nil
}
// SetIcon sets the tray icon.
// Use: _ = manager.SetIcon(iconBytes)
func (m *Manager) SetIcon(data []byte) error {
if m.tray == nil {
return fmt.Errorf("tray not initialised")
return core.E("systray.SetIcon", "tray not initialised", nil)
}
m.tray.SetIcon(data)
m.hasIcon = len(data) > 0
return nil
}
// SetTemplateIcon sets the template icon (macOS).
// Use: _ = manager.SetTemplateIcon(iconBytes)
func (m *Manager) SetTemplateIcon(data []byte) error {
if m.tray == nil {
return fmt.Errorf("tray not initialised")
return core.E("systray.SetTemplateIcon", "tray not initialised", nil)
}
m.tray.SetTemplateIcon(data)
m.hasTemplateIcon = len(data) > 0
return nil
}
// SetTooltip sets the tray tooltip.
// Use: _ = manager.SetTooltip("Core is ready")
func (m *Manager) SetTooltip(text string) error {
if m.tray == nil {
return fmt.Errorf("tray not initialised")
return core.E("systray.SetTooltip", "tray not initialised", nil)
}
m.tray.SetTooltip(text)
m.tooltip = text
return nil
}
// SetLabel sets the tray label.
// Use: _ = manager.SetLabel("Core")
func (m *Manager) SetLabel(text string) error {
if m.tray == nil {
return fmt.Errorf("tray not initialised")
return core.E("systray.SetLabel", "tray not initialised", nil)
}
m.tray.SetLabel(text)
m.label = text
return nil
}
// AttachWindow attaches a panel window to the tray.
// Use: _ = manager.AttachWindow(windowHandle)
func (m *Manager) AttachWindow(w WindowHandle) error {
if m.tray == nil {
return fmt.Errorf("tray not initialised")
return core.E("systray.AttachWindow", "tray not initialised", nil)
}
m.mu.Lock()
m.panelWindow = w
m.mu.Unlock()
m.tray.AttachWindow(w)
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.
// Use: tray := manager.Tray()
func (m *Manager) Tray() PlatformTray {
return m.tray
}
// IsActive returns whether a tray has been created.
// Use: active := manager.IsActive()
func (m *Manager) IsActive() bool {
return m.tray != nil
}

View file

@ -6,31 +6,37 @@ import (
)
// WailsPlatform implements Platform using Wails v3.
// Use: platform := systray.NewWailsPlatform(app)
type WailsPlatform struct {
app *application.App
}
// NewWailsPlatform creates a Wails-backed tray platform.
// Use: platform := systray.NewWailsPlatform(app)
func NewWailsPlatform(app *application.App) *WailsPlatform {
return &WailsPlatform{app: app}
}
// NewTray creates a Wails system tray handle.
// Use: tray := platform.NewTray()
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 {
return &wailsTrayMenu{menu: wp.app.NewMenu()}
}
type wailsTray struct {
tray *application.SystemTray
app *application.App
}
func (wt *wailsTray) SetIcon(data []byte) { wt.tray.SetIcon(data) }
func (wt *wailsTray) SetTemplateIcon(data []byte) { wt.tray.SetTemplateIcon(data) }
func (wt *wailsTray) SetTooltip(text string) { wt.tray.SetTooltip(text) }
func (wt *wailsTray) SetLabel(text string) { wt.tray.SetLabel(text) }
func (wt *wailsTray) SetTemplateIcon(data []byte) { wt.tray.SetTemplateIcon(data) }
func (wt *wailsTray) SetTooltip(text string) { wt.tray.SetTooltip(text) }
func (wt *wailsTray) SetLabel(text string) { wt.tray.SetLabel(text) }
func (wt *wailsTray) SetMenu(menu PlatformMenu) {
if wm, ok := menu.(*wailsTrayMenu); ok {
@ -39,8 +45,19 @@ func (wt *wailsTray) SetMenu(menu PlatformMenu) {
}
func (wt *wailsTray) AttachWindow(w WindowHandle) {
// Wails systray AttachWindow expects an application.Window interface.
// The caller must pass an appropriate wrapper.
if wt.tray == nil {
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.
@ -56,12 +73,16 @@ func (m *wailsTrayMenu) AddSeparator() {
m.menu.AddSeparator()
}
func (m *wailsTrayMenu) AddSubmenu(label string) PlatformMenu {
return &wailsTrayMenu{menu: m.menu.AddSubmenu(label)}
}
// wailsTrayMenuItem wraps *application.MenuItem for the PlatformMenuItem interface.
type wailsTrayMenuItem struct {
item *application.MenuItem
}
func (mi *wailsTrayMenuItem) SetTooltip(text string) { mi.item.SetTooltip(text) }
func (mi *wailsTrayMenuItem) SetTooltip(text string) { mi.item.SetTooltip(text) }
func (mi *wailsTrayMenuItem) SetChecked(checked bool) { mi.item.SetChecked(checked) }
func (mi *wailsTrayMenuItem) SetEnabled(enabled bool) { mi.item.SetEnabled(enabled) }
func (mi *wailsTrayMenuItem) OnClick(fn func()) {

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) ---
// 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
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
// Use: result, _, err := c.QUERY(webview.QueryConsole{Window: "editor", Level: "error", Limit: 20})
type QueryConsole struct {
Window string `json:"window"`
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)
// Use: result, _, err := c.QUERY(webview.QuerySelector{Window: "editor", Selector: "#submit"})
type QuerySelector struct {
Window string `json:"window"`
Selector string `json:"selector"`
}
// QuerySelectorAll finds all matching elements. Result: []*ElementInfo
// Use: result, _, err := c.QUERY(webview.QuerySelectorAll{Window: "editor", Selector: "button"})
type QuerySelectorAll struct {
Window string `json:"window"`
Selector string `json:"selector"`
}
// QueryDOMTree gets HTML content. Result: string (outerHTML)
// Use: result, _, err := c.QUERY(webview.QueryDOMTree{Window: "editor", Selector: "main"})
type QueryDOMTree struct {
Window string `json:"window"`
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) ---
// TaskEvaluate executes JavaScript. Result: any (JS return value)
// Use: _, _, err := c.PERFORM(webview.TaskEvaluate{Window: "editor", Script: "document.title"})
type TaskEvaluate struct {
Window string `json:"window"`
Script string `json:"script"`
}
// TaskClick clicks an element. Result: nil
// Use: _, _, err := c.PERFORM(webview.TaskClick{Window: "editor", Selector: "#submit"})
type TaskClick struct {
Window string `json:"window"`
Selector string `json:"selector"`
}
// TaskType types text into an element. Result: nil
// Use: _, _, err := c.PERFORM(webview.TaskType{Window: "editor", Selector: "#search", Text: "core"})
type TaskType struct {
Window string `json:"window"`
Selector string `json:"selector"`
@ -58,15 +104,27 @@ type TaskType struct {
}
// TaskNavigate navigates to a URL. Result: nil
// Use: _, _, err := c.PERFORM(webview.TaskNavigate{Window: "editor", URL: "https://example.com"})
type TaskNavigate struct {
Window string `json:"window"`
URL string `json:"url"`
}
// 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
// Use: _, _, err := c.PERFORM(webview.TaskScroll{Window: "editor", X: 0, Y: 600})
type TaskScroll struct {
Window string `json:"window"`
X int `json:"x"`
@ -74,12 +132,14 @@ type TaskScroll struct {
}
// TaskHover hovers over an element. Result: nil
// Use: _, _, err := c.PERFORM(webview.TaskHover{Window: "editor", Selector: "#help"})
type TaskHover struct {
Window string `json:"window"`
Selector string `json:"selector"`
}
// 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 {
Window string `json:"window"`
Selector string `json:"selector"`
@ -87,6 +147,7 @@ type TaskSelect struct {
}
// TaskCheck checks/unchecks a checkbox. Result: nil
// Use: _, _, err := c.PERFORM(webview.TaskCheck{Window: "editor", Selector: "#accept", Checked: true})
type TaskCheck struct {
Window string `json:"window"`
Selector string `json:"selector"`
@ -94,6 +155,7 @@ type TaskCheck struct {
}
// 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 {
Window string `json:"window"`
Selector string `json:"selector"`
@ -101,6 +163,7 @@ type TaskUploadFile struct {
}
// TaskSetViewport sets the viewport dimensions. Result: nil
// Use: _, _, err := c.PERFORM(webview.TaskSetViewport{Window: "editor", Width: 1280, Height: 800})
type TaskSetViewport struct {
Window string `json:"window"`
Width int `json:"width"`
@ -108,17 +171,66 @@ type TaskSetViewport struct {
}
// 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) ---
// 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 {
Window string `json:"window"`
Message ConsoleMessage `json:"message"`
}
// ActionException is broadcast when a JavaScript exception occurs.
// Use: _ = c.ACTION(webview.ActionException{Window: "editor", Exception: webview.ExceptionInfo{Text: "ReferenceError"}})
type ActionException struct {
Window string `json:"window"`
Exception ExceptionInfo `json:"exception"`
@ -127,6 +239,7 @@ type ActionException struct {
// --- Types ---
// ConsoleMessage represents a browser console message.
// Use: msg := webview.ConsoleMessage{Type: "warn", Text: "slow network"}
type ConsoleMessage struct {
Type string `json:"type"` // "log", "warn", "error", "info", "debug"
Text string `json:"text"`
@ -137,6 +250,7 @@ type ConsoleMessage struct {
}
// ElementInfo represents a DOM element.
// Use: el := webview.ElementInfo{TagName: "button", InnerText: "Save"}
type ElementInfo struct {
TagName string `json:"tagName"`
Attributes map[string]string `json:"attributes,omitempty"`
@ -146,6 +260,7 @@ type ElementInfo struct {
}
// BoundingBox represents element position and size.
// Use: box := webview.BoundingBox{X: 10, Y: 20, Width: 120, Height: 40}
type BoundingBox struct {
X float64 `json:"x"`
Y float64 `json:"y"`
@ -155,6 +270,7 @@ type BoundingBox struct {
// ExceptionInfo represents a JavaScript exception.
// Field mapping from go-webview: LineNumber->Line, ColumnNumber->Column.
// Use: err := webview.ExceptionInfo{Text: "ReferenceError", URL: "app://editor"}
type ExceptionInfo struct {
Text string `json:"text"`
URL string `json:"url,omitempty"`
@ -165,7 +281,52 @@ type ExceptionInfo struct {
}
// ScreenshotResult wraps raw PNG bytes as base64 for JSON/MCP transport.
// Use: shot := webview.ScreenshotResult{Base64: "iVBORw0KGgo=", MimeType: "image/png"}
type ScreenshotResult struct {
Base64 string `json:"base64"`
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

@ -2,12 +2,21 @@
package webview
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"image"
"image/draw"
"image/png"
"math"
"reflect"
"strconv"
"strings"
"sync"
"time"
"unsafe"
gowebview "forge.lthn.ai/core/go-webview"
"forge.lthn.ai/core/go/pkg/core"
@ -34,10 +43,13 @@ type connector interface {
ClearConsole()
SetViewport(width, height int) error
UploadFile(selector string, paths []string) error
Print() error
PrintToPDF() ([]byte, error)
Close() error
}
// Options holds configuration for the webview service.
// Use: svc, err := webview.Register(webview.Options{})(core.New())
type Options struct {
DebugURL string // Chrome debug endpoint (default: "http://localhost:9222")
Timeout time.Duration // Operation timeout (default: 30s)
@ -45,30 +57,40 @@ type Options struct {
}
// Service is a core.Service managing webview interactions via IPC.
// Use: svc, err := webview.Register(webview.Options{})(core.New())
type Service struct {
*core.ServiceRuntime[Options]
opts Options
connections map[string]connector
exceptions map[string][]ExceptionInfo
mu sync.RWMutex
newConn func(debugURL, windowName string) (connector, error) // injectable for tests
watcherSetup func(conn connector, windowName string) // called after connection creation
}
// Register creates a factory closure with the given options.
func Register(opts ...func(*Options)) func(*core.Core) (any, error) {
// Register creates a factory closure with declarative options.
// Use: core.WithService(webview.Register(webview.Options{ConsoleLimit: 500}))
func Register(options Options) func(*core.Core) (any, error) {
o := Options{
DebugURL: "http://localhost:9222",
Timeout: 30 * time.Second,
ConsoleLimit: 1000,
}
for _, fn := range opts {
fn(&o)
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) {
svc := &Service{
ServiceRuntime: core.NewServiceRuntime[Options](c, o),
opts: o,
connections: make(map[string]connector),
exceptions: make(map[string][]ExceptionInfo),
newConn: defaultNewConn(o),
}
svc.watcherSetup = svc.defaultWatcherSetup
@ -181,7 +203,10 @@ func (s *Service) HandleIPCEvents(_ *core.Core, msg core.Message) error {
conn.Close()
delete(s.connections, m.Name)
}
delete(s.exceptions, m.Name)
s.mu.Unlock()
case ActionException:
s.recordException(m.Window, m.Exception)
}
return nil
}
@ -274,6 +299,64 @@ func (s *Service) handleQuery(_ *core.Core, q core.Query) (any, bool, error) {
}
html, err := conn.GetHTML(selector)
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:
return nil, false, nil
}
@ -319,6 +402,19 @@ func (s *Service) handleTask(_ *core.Core, t core.Task) (any, bool, error) {
Base64: base64.StdEncoding.EncodeToString(png),
MimeType: "image/png",
}, 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:
conn, err := s.getConn(t.Window)
if err != nil {
@ -363,28 +459,264 @@ func (s *Service) handleTask(_ *core.Core, t core.Task) (any, bool, error) {
}
conn.ClearConsole()
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:
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.
type realConnector struct {
wv *gowebview.Webview
}
func (r *realConnector) Navigate(url string) error { return r.wv.Navigate(url) }
func (r *realConnector) Click(sel string) error { return r.wv.Click(sel) }
func (r *realConnector) Type(sel, text string) error { return r.wv.Type(sel, text) }
func (r *realConnector) Evaluate(script string) (any, error) { return r.wv.Evaluate(script) }
func (r *realConnector) Screenshot() ([]byte, error) { return r.wv.Screenshot() }
func (r *realConnector) GetURL() (string, error) { return r.wv.GetURL() }
func (r *realConnector) GetTitle() (string, error) { return r.wv.GetTitle() }
func (r *realConnector) GetHTML(sel string) (string, error) { return r.wv.GetHTML(sel) }
func (r *realConnector) ClearConsole() { r.wv.ClearConsole() }
func (r *realConnector) Close() error { return r.wv.Close() }
func (r *realConnector) SetViewport(w, h int) error { return r.wv.SetViewport(w, h) }
func (r *realConnector) Navigate(url string) error { return r.wv.Navigate(url) }
func (r *realConnector) Click(sel string) error { return r.wv.Click(sel) }
func (r *realConnector) Type(sel, text string) error { return r.wv.Type(sel, text) }
func (r *realConnector) Evaluate(script string) (any, error) { return r.wv.Evaluate(script) }
func (r *realConnector) Screenshot() ([]byte, error) { return r.wv.Screenshot() }
func (r *realConnector) GetURL() (string, error) { return r.wv.GetURL() }
func (r *realConnector) GetTitle() (string, error) { return r.wv.GetTitle() }
func (r *realConnector) GetHTML(sel string) (string, error) { return r.wv.GetHTML(sel) }
func (r *realConnector) ClearConsole() { r.wv.ClearConsole() }
func (r *realConnector) Print() error { _, err := r.wv.Evaluate("window.print()"); return err }
func (r *realConnector) Close() error { return r.wv.Close() }
func (r *realConnector) SetViewport(w, h int) error { return r.wv.SetViewport(w, h) }
func (r *realConnector) UploadFile(sel string, p []string) error { return r.wv.UploadFile(sel, p) }
func (r *realConnector) 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 {
return gowebview.NewActionSequence().Add(&gowebview.HoverAction{Selector: sel}).Execute(context.Background(), r.wv)

View file

@ -2,7 +2,13 @@
package webview
import (
"bytes"
"context"
"encoding/base64"
"image"
"image/color"
"image/png"
"strings"
"testing"
"forge.lthn.ai/core/go/pkg/core"
@ -12,14 +18,17 @@ import (
)
type mockConnector struct {
url string
title string
html string
evalResult any
screenshot []byte
console []ConsoleMessage
elements []*ElementInfo
closed bool
url string
title string
html string
evalResult any
evalFn func(script string) (any, error)
screenshot []byte
console []ConsoleMessage
elements []*ElementInfo
closed bool
pdfBytes []byte
printCalled bool
lastClickSel string
lastTypeSel string
@ -35,23 +44,57 @@ type mockConnector struct {
lastViewportW int
lastViewportH int
consoleClearCalled bool
lastEvalScript string
}
func (m *mockConnector) Navigate(url string) error { m.lastNavURL = url; return nil }
func (m *mockConnector) Click(sel string) error { m.lastClickSel = sel; return nil }
func (m *mockConnector) Type(sel, text string) error { m.lastTypeSel = sel; m.lastTypeText = text; return nil }
func (m *mockConnector) Hover(sel string) error { m.lastHoverSel = sel; return nil }
func (m *mockConnector) Select(sel, val string) error { m.lastSelectSel = sel; m.lastSelectVal = val; return nil }
func (m *mockConnector) Check(sel string, c bool) error { m.lastCheckSel = sel; m.lastCheckVal = c; return nil }
func (m *mockConnector) Evaluate(s string) (any, error) { return m.evalResult, nil }
func (m *mockConnector) Screenshot() ([]byte, error) { return m.screenshot, nil }
func (m *mockConnector) GetURL() (string, error) { return m.url, nil }
func (m *mockConnector) GetTitle() (string, error) { return m.title, nil }
func (m *mockConnector) Navigate(url string) error { m.lastNavURL = url; return nil }
func (m *mockConnector) Click(sel string) error { m.lastClickSel = sel; return nil }
func (m *mockConnector) Type(sel, text string) error {
m.lastTypeSel = sel
m.lastTypeText = text
return nil
}
func (m *mockConnector) Hover(sel string) error { m.lastHoverSel = sel; return nil }
func (m *mockConnector) Select(sel, val string) error {
m.lastSelectSel = sel
m.lastSelectVal = val
return nil
}
func (m *mockConnector) Check(sel string, c bool) error {
m.lastCheckSel = sel
m.lastCheckVal = c
return nil
}
func (m *mockConnector) Evaluate(s string) (any, error) {
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) GetURL() (string, error) { return m.url, nil }
func (m *mockConnector) GetTitle() (string, error) { return m.title, nil }
func (m *mockConnector) GetHTML(sel string) (string, error) { return m.html, nil }
func (m *mockConnector) ClearConsole() { m.consoleClearCalled = true }
func (m *mockConnector) Close() error { m.closed = true; return nil }
func (m *mockConnector) SetViewport(w, h int) error { m.lastViewportW = w; m.lastViewportH = h; return nil }
func (m *mockConnector) UploadFile(sel string, p []string) error { m.lastUploadSel = sel; m.lastUploadPaths = p; return nil }
func (m *mockConnector) ClearConsole() { m.consoleClearCalled = true }
func (m *mockConnector) Print() error { m.printCalled = true; return nil }
func (m *mockConnector) Close() error { m.closed = true; return nil }
func (m *mockConnector) SetViewport(w, h int) error {
m.lastViewportW = w
m.lastViewportH = h
return nil
}
func (m *mockConnector) 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 {
m.lastUploadSel = sel
m.lastUploadPaths = p
return nil
}
func (m *mockConnector) QuerySelector(sel string) (*ElementInfo, error) {
if len(m.elements) > 0 {
@ -68,8 +111,12 @@ func (m *mockConnector) GetConsole() []ConsoleMessage { return m.console }
func newTestService(t *testing.T, mock *mockConnector) (*Service, *core.Core) {
t.Helper()
factory := Register()
c, err := core.New(core.WithService(factory), core.WithServiceLock())
factory := Register(Options{})
c, err := core.New(
core.WithService(window.Register(window.NewMockPlatform())),
core.WithService(factory),
core.WithServiceLock(),
)
require.NoError(t, err)
require.NoError(t, c.ServiceStartup(context.Background(), nil))
svc := core.MustServiceFor[*Service](c, "webview")
@ -127,6 +174,29 @@ func TestQueryConsole_Good_Limit(t *testing.T) {
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) {
_, c := newTestService(t, &mockConnector{evalResult: 42})
result, handled, err := c.PERFORM(TaskEvaluate{Window: "main", Script: "21*2"})
@ -165,6 +235,43 @@ func TestTaskScreenshot_Good(t *testing.T) {
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) {
mock := &mockConnector{}
_, c := newTestService(t, mock)
@ -174,6 +281,102 @@ func TestTaskClearConsole_Good(t *testing.T) {
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) {
mock := &mockConnector{}
_, c := newTestService(t, mock)

View file

@ -11,6 +11,7 @@ import (
)
// Layout is a named window arrangement.
// Use: layout := window.Layout{Name: "coding"}
type Layout struct {
Name string `json:"name"`
Windows map[string]WindowState `json:"windows"`
@ -19,6 +20,7 @@ type Layout struct {
}
// LayoutInfo is a summary of a layout.
// Use: info := window.LayoutInfo{Name: "coding", WindowCount: 2}
type LayoutInfo struct {
Name string `json:"name"`
WindowCount int `json:"windowCount"`
@ -27,6 +29,7 @@ type LayoutInfo struct {
}
// LayoutManager persists named window arrangements to ~/.config/Core/layouts.json.
// Use: lm := window.NewLayoutManager()
type LayoutManager struct {
configDir string
layouts map[string]Layout
@ -34,6 +37,7 @@ type LayoutManager struct {
}
// NewLayoutManager creates a LayoutManager loading from the default config directory.
// Use: lm := window.NewLayoutManager()
func NewLayoutManager() *LayoutManager {
lm := &LayoutManager{
layouts: make(map[string]Layout),
@ -42,30 +46,31 @@ func NewLayoutManager() *LayoutManager {
if err == nil {
lm.configDir = filepath.Join(configDir, "Core")
}
lm.load()
lm.loadLayouts()
return lm
}
// NewLayoutManagerWithDir creates a LayoutManager loading from a custom config directory.
// Useful for testing or when the default config directory is not appropriate.
// Use: lm := window.NewLayoutManagerWithDir(t.TempDir())
func NewLayoutManagerWithDir(configDir string) *LayoutManager {
lm := &LayoutManager{
configDir: configDir,
layouts: make(map[string]Layout),
}
lm.load()
lm.loadLayouts()
return lm
}
func (lm *LayoutManager) filePath() string {
func (lm *LayoutManager) layoutsFilePath() string {
return filepath.Join(lm.configDir, "layouts.json")
}
func (lm *LayoutManager) load() {
func (lm *LayoutManager) loadLayouts() {
if lm.configDir == "" {
return
}
data, err := os.ReadFile(lm.filePath())
data, err := os.ReadFile(lm.layoutsFilePath())
if err != nil {
return
}
@ -74,7 +79,7 @@ func (lm *LayoutManager) load() {
_ = json.Unmarshal(data, &lm.layouts)
}
func (lm *LayoutManager) save() {
func (lm *LayoutManager) saveLayouts() {
if lm.configDir == "" {
return
}
@ -85,10 +90,11 @@ func (lm *LayoutManager) save() {
return
}
_ = os.MkdirAll(lm.configDir, 0o755)
_ = os.WriteFile(lm.filePath(), data, 0o644)
_ = os.WriteFile(lm.layoutsFilePath(), data, 0o644)
}
// SaveLayout creates or updates a named layout.
// Use: _ = lm.SaveLayout("coding", windowStates)
func (lm *LayoutManager) SaveLayout(name string, windowStates map[string]WindowState) error {
if name == "" {
return fmt.Errorf("layout name cannot be empty")
@ -108,11 +114,12 @@ func (lm *LayoutManager) SaveLayout(name string, windowStates map[string]WindowS
}
lm.layouts[name] = layout
lm.mu.Unlock()
lm.save()
lm.saveLayouts()
return nil
}
// GetLayout returns a layout by name.
// Use: layout, ok := lm.GetLayout("coding")
func (lm *LayoutManager) GetLayout(name string) (Layout, bool) {
lm.mu.RLock()
defer lm.mu.RUnlock()
@ -121,6 +128,7 @@ func (lm *LayoutManager) GetLayout(name string) (Layout, bool) {
}
// ListLayouts returns info summaries for all layouts.
// Use: layouts := lm.ListLayouts()
func (lm *LayoutManager) ListLayouts() []LayoutInfo {
lm.mu.RLock()
defer lm.mu.RUnlock()
@ -135,9 +143,10 @@ func (lm *LayoutManager) ListLayouts() []LayoutInfo {
}
// DeleteLayout removes a layout by name.
// Use: lm.DeleteLayout("coding")
func (lm *LayoutManager) DeleteLayout(name string) {
lm.mu.Lock()
delete(lm.layouts, name)
lm.mu.Unlock()
lm.save()
lm.saveLayouts()
}

View file

@ -1,6 +1,8 @@
// pkg/window/messages.go
package window
// WindowInfo contains information about a window.
// Use: info := window.WindowInfo{Name: "editor", Title: "Core Editor"}
type WindowInfo struct {
Name string `json:"name"`
Title string `json:"title"`
@ -8,67 +10,143 @@ type WindowInfo struct {
Y int `json:"y"`
Width int `json:"width"`
Height int `json:"height"`
Visible bool `json:"visible"`
Minimized bool `json:"minimized"`
Maximized bool `json:"maximized"`
Focused bool `json:"focused"`
}
// Bounds describes the position and size of a window.
// Use: bounds := window.Bounds{X: 10, Y: 10, Width: 1280, Height: 800}
type Bounds struct {
X int `json:"x"`
Y int `json:"y"`
Width int `json:"width"`
Height int `json:"height"`
}
// --- 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
type TaskOpenWindow struct{ Opts []WindowOption }
// Use: _, _, err := c.PERFORM(window.TaskOpenWindow{Opts: []window.WindowOption{window.WithName("editor")}})
type TaskOpenWindow struct {
Window *Window
Opts []WindowOption
}
// TaskCloseWindow closes a window. Handler persists state BEFORE emitting ActionWindowClosed.
// 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 }
// TaskSetPosition moves a window.
// Use: _, _, err := c.PERFORM(window.TaskSetPosition{Name: "editor", X: 160, Y: 120})
type TaskSetPosition struct {
Name string
X, Y int
}
// TaskSetSize resizes a window.
// Use: _, _, err := c.PERFORM(window.TaskSetSize{Name: "editor", Width: 1280, Height: 800})
type TaskSetSize struct {
Name string
Name string
Width, Height int
// W and H are compatibility aliases for older call sites.
W, H int
}
// TaskMaximise maximises a window.
// Use: _, _, err := c.PERFORM(window.TaskMaximise{Name: "editor"})
type TaskMaximise struct{ Name string }
// TaskMinimise minimises a window.
// 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 }
// TaskRestore restores a maximised or minimised window to its normal state.
// Use: _, _, err := c.PERFORM(window.TaskRestore{Name: "editor"})
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 {
Name string
Title string
}
// TaskSetAlwaysOnTop pins a window above others.
// Use: _, _, err := c.PERFORM(window.TaskSetAlwaysOnTop{Name: "editor", AlwaysOnTop: true})
type TaskSetAlwaysOnTop struct {
Name string
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 {
Name string
Red uint8
Green uint8
Blue 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 {
Name string
Visible bool
}
// TaskFullscreen enters or exits fullscreen mode.
// Use: _, _, err := c.PERFORM(window.TaskFullscreen{Name: "editor", Fullscreen: true})
type TaskFullscreen struct {
Name string
Fullscreen bool
@ -77,57 +155,135 @@ type TaskFullscreen struct {
// --- Layout Queries ---
// QueryLayoutList returns summaries of all saved layouts. Result: []LayoutInfo
// Use: result, _, err := c.QUERY(window.QueryLayoutList{})
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 }
// --- 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 }
// TaskRestoreLayout restores a saved layout by name.
// Use: _, _, err := c.PERFORM(window.TaskRestoreLayout{Name: "coding"})
type TaskRestoreLayout struct{ Name string }
// TaskDeleteLayout removes a saved layout by name.
// Use: _, _, err := c.PERFORM(window.TaskDeleteLayout{Name: "coding"})
type TaskDeleteLayout struct{ Name string }
// TaskTileWindows arranges windows in a tiling mode.
// Use: _, _, err := c.PERFORM(window.TaskTileWindows{Mode: "grid"})
type TaskTileWindows struct {
Mode string // "left-right", "grid", "left-half", "right-half", etc.
Windows []string // window names; empty = all
}
// TaskSnapWindow snaps a window to a screen edge/corner.
// Use: _, _, err := c.PERFORM(window.TaskSnapWindow{Name: "editor", Position: "left"})
type TaskSnapWindow struct {
Name string // window name
Position string // "left", "right", "top", "bottom", "top-left", "top-right", "bottom-left", "bottom-right", "center"
}
// TaskArrangePair places two windows side-by-side in a balanced split.
// Use: _, _, err := c.PERFORM(window.TaskArrangePair{First: "editor", Second: "terminal"})
type TaskArrangePair struct {
First string
Second string
}
// TaskBesideEditor places a target window beside an editor/IDE window.
// 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 }
// ActionWindowClosed is broadcast when a window is closed.
// Use: _ = c.ACTION(window.ActionWindowClosed{Name: "editor"})
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 {
Name string
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 {
Name string
Name string
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 }
// ActionWindowBlurred is broadcast when a window loses focus.
// Use: _ = c.ACTION(window.ActionWindowBlurred{Name: "editor"})
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 {
Name string `json:"name"` // window name
Name string `json:"name"` // window name
Paths []string `json:"paths"`
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,25 +1,36 @@
// pkg/window/mock_platform.go
package window
// 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.
type MockPlatform struct {
Windows []*MockWindow
}
// NewMockPlatform creates a window platform mock.
// Use: platform := window.NewMockPlatform()
func NewMockPlatform() *MockPlatform {
return &MockPlatform{}
}
// 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{
name: opts.Name, title: opts.Title, url: opts.URL,
width: opts.Width, height: opts.Height,
x: opts.X, y: opts.Y,
alwaysOnTop: opts.AlwaysOnTop,
backgroundColor: opts.BackgroundColour,
visible: !opts.Hidden,
}
m.Windows = append(m.Windows, w)
return w
}
// GetWindows returns all tracked mock windows.
// Use: windows := platform.GetWindows()
func (m *MockPlatform) GetWindows() []PlatformWindow {
out := make([]PlatformWindow, len(m.Windows))
for i, w := range m.Windows {
@ -28,38 +39,65 @@ func (m *MockPlatform) GetWindows() []PlatformWindow {
return out
}
// MockWindow is an in-memory window handle used by tests.
// Use: w := &window.MockWindow{}
type MockWindow struct {
name, title, url string
width, height, x, y int
maximised, focused bool
maximised, minimised bool
focused bool
visible, alwaysOnTop bool
backgroundColor [4]uint8
opacity float32
closed bool
eventHandlers []func(WindowEvent)
fileDropHandlers []func(paths []string, targetID string)
}
func (w *MockWindow) Name() string { return w.name }
func (w *MockWindow) Title() string { return w.title }
func (w *MockWindow) Position() (int, int) { return w.x, w.y }
func (w *MockWindow) Size() (int, int) { return w.width, w.height }
func (w *MockWindow) IsMaximised() bool { return w.maximised }
func (w *MockWindow) IsFocused() bool { return w.focused }
func (w *MockWindow) SetTitle(title string) { w.title = title }
func (w *MockWindow) SetPosition(x, y int) { w.x = x; w.y = y }
func (w *MockWindow) SetSize(width, height int) { w.width = width; w.height = height }
func (w *MockWindow) SetBackgroundColour(r, g, b, a uint8) {}
func (w *MockWindow) SetVisibility(visible bool) { w.visible = visible }
func (w *MockWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop }
func (w *MockWindow) Maximise() { w.maximised = true }
func (w *MockWindow) Restore() { w.maximised = false }
func (w *MockWindow) Minimise() {}
func (w *MockWindow) Focus() { w.focused = true }
func (w *MockWindow) Close() { w.closed = true }
func (w *MockWindow) Show() { w.visible = true }
func (w *MockWindow) Hide() { w.visible = false }
func (w *MockWindow) Fullscreen() {}
func (w *MockWindow) UnFullscreen() {}
func (w *MockWindow) OnWindowEvent(handler func(WindowEvent)) { w.eventHandlers = append(w.eventHandlers, handler) }
func (w *MockWindow) Name() string { return w.name }
func (w *MockWindow) Title() string { return w.title }
func (w *MockWindow) Position() (int, int) { return w.x, w.y }
func (w *MockWindow) Size() (int, int) { return w.width, w.height }
func (w *MockWindow) 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) SetTitle(title string) { w.title = title }
func (w *MockWindow) SetPosition(x, y int) { w.x = x; w.y = y }
func (w *MockWindow) SetSize(width, height int) { w.width = width; w.height = height }
func (w *MockWindow) SetBackgroundColour(r, g, b, a uint8) { 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) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop }
func (w *MockWindow) Maximise() { w.maximised = true; w.minimised = false; w.visible = true }
func (w *MockWindow) Restore() { w.maximised = false; w.minimised = false; w.visible = true }
func (w *MockWindow) Minimise() { w.minimised = true; w.maximised = false; w.visible = false }
func (w *MockWindow) Focus() { w.focused = true }
func (w *MockWindow) Close() {
w.closed = true
w.emit(WindowEvent{Type: "close", Name: w.name})
}
func (w *MockWindow) Show() { w.visible = true }
func (w *MockWindow) Hide() { w.visible = false }
func (w *MockWindow) Fullscreen() {}
func (w *MockWindow) UnFullscreen() {}
func (w *MockWindow) OpenDevTools() {}
func (w *MockWindow) CloseDevTools() {}
func (w *MockWindow) OnWindowEvent(handler func(WindowEvent)) {
w.eventHandlers = append(w.eventHandlers, handler)
}
func (w *MockWindow) OnFileDrop(handler func(paths []string, targetID string)) {
w.fileDropHandlers = append(w.fileDropHandlers, handler)
}
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

@ -14,6 +14,9 @@ func (m *mockPlatform) CreateWindow(opts PlatformWindowOptions) PlatformWindow {
name: opts.Name, title: opts.Title, url: opts.URL,
width: opts.Width, height: opts.Height,
x: opts.X, y: opts.Y,
alwaysOnTop: opts.AlwaysOnTop,
backgroundColor: opts.BackgroundColour,
visible: !opts.Hidden,
}
m.windows = append(m.windows, w)
return w
@ -30,35 +33,49 @@ func (m *mockPlatform) GetWindows() []PlatformWindow {
type mockWindow struct {
name, title, url string
width, height, x, y int
maximised, focused bool
maximised, minimised bool
focused bool
visible, alwaysOnTop bool
backgroundColor [4]uint8
opacity float32
devtoolsOpen bool
closed bool
eventHandlers []func(WindowEvent)
fileDropHandlers []func(paths []string, targetID string)
}
func (w *mockWindow) Name() string { return w.name }
func (w *mockWindow) Title() string { return w.title }
func (w *mockWindow) Position() (int, int) { return w.x, w.y }
func (w *mockWindow) Size() (int, int) { return w.width, w.height }
func (w *mockWindow) IsMaximised() bool { return w.maximised }
func (w *mockWindow) IsFocused() bool { return w.focused }
func (w *mockWindow) SetTitle(title string) { w.title = title }
func (w *mockWindow) SetPosition(x, y int) { w.x = x; w.y = y }
func (w *mockWindow) SetSize(width, height int) { w.width = width; w.height = height }
func (w *mockWindow) SetBackgroundColour(r, g, b, a uint8) {}
func (w *mockWindow) SetVisibility(visible bool) { w.visible = visible }
func (w *mockWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop }
func (w *mockWindow) Maximise() { w.maximised = true }
func (w *mockWindow) Restore() { w.maximised = false }
func (w *mockWindow) Minimise() {}
func (w *mockWindow) Focus() { w.focused = true }
func (w *mockWindow) Close() { w.closed = true }
func (w *mockWindow) Show() { w.visible = true }
func (w *mockWindow) Hide() { w.visible = false }
func (w *mockWindow) Fullscreen() {}
func (w *mockWindow) UnFullscreen() {}
func (w *mockWindow) OnWindowEvent(handler func(WindowEvent)) { w.eventHandlers = append(w.eventHandlers, handler) }
func (w *mockWindow) Name() string { return w.name }
func (w *mockWindow) Title() string { return w.title }
func (w *mockWindow) Position() (int, int) { return w.x, w.y }
func (w *mockWindow) Size() (int, int) { return w.width, w.height }
func (w *mockWindow) 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) SetTitle(title string) { w.title = title }
func (w *mockWindow) SetPosition(x, y int) { w.x = x; w.y = y }
func (w *mockWindow) SetSize(width, height int) { w.width = width; w.height = height }
func (w *mockWindow) SetBackgroundColour(r, g, b, a uint8) { 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) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop }
func (w *mockWindow) Maximise() { w.maximised = true; w.minimised = false; w.visible = true }
func (w *mockWindow) Restore() { w.maximised = false; w.minimised = false; w.visible = true }
func (w *mockWindow) Minimise() { w.minimised = true; w.maximised = false; w.visible = false }
func (w *mockWindow) Focus() { w.focused = true }
func (w *mockWindow) Close() {
w.closed = true
w.emit(WindowEvent{Type: "close", Name: w.name})
}
func (w *mockWindow) Show() { w.visible = true }
func (w *mockWindow) Hide() { w.visible = false }
func (w *mockWindow) Fullscreen() {}
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)) {
w.eventHandlers = append(w.eventHandlers, handler)
}
func (w *mockWindow) OnFileDrop(handler func(paths []string, targetID string)) {
w.fileDropHandlers = append(w.fileDropHandlers, handler)
}

View file

@ -5,6 +5,7 @@ package window
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 {
@ -18,50 +19,74 @@ func ApplyOptions(opts ...WindowOption) (*Window, error) {
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

@ -2,29 +2,32 @@
package window
// Platform abstracts the windowing backend (Wails v3).
// Use: var p window.Platform
type Platform interface {
CreateWindow(opts PlatformWindowOptions) PlatformWindow
GetWindows() []PlatformWindow
}
// PlatformWindowOptions are the backend-specific options passed to CreateWindow.
// Use: opts := window.PlatformWindowOptions{Name: "editor", URL: "/editor"}
type PlatformWindowOptions struct {
Name string
Title string
URL string
Width, Height int
X, Y int
MinWidth, MinHeight int
MaxWidth, MaxHeight int
Frameless bool
Hidden bool
AlwaysOnTop bool
BackgroundColour [4]uint8 // RGBA
DisableResize bool
EnableFileDrop bool
Name string
Title string
URL string
Width, Height int
X, Y int
MinWidth, MinHeight int
MaxWidth, MaxHeight int
Frameless bool
Hidden bool
AlwaysOnTop bool
BackgroundColour [4]uint8 // RGBA
DisableResize bool
EnableFileDrop bool
}
// PlatformWindow is a live window handle from the backend.
// Use: var w window.PlatformWindow
type PlatformWindow interface {
// Identity
Name() string
@ -33,6 +36,8 @@ type PlatformWindow interface {
// Queries
Position() (int, int)
Size() (int, int)
IsVisible() bool
IsMinimised() bool
IsMaximised() bool
IsFocused() bool
@ -41,6 +46,7 @@ type PlatformWindow interface {
SetPosition(x, y int)
SetSize(width, height int)
SetBackgroundColour(r, g, b, a uint8)
SetOpacity(opacity float32)
SetVisibility(visible bool)
SetAlwaysOnTop(alwaysOnTop bool)
@ -54,6 +60,8 @@ type PlatformWindow interface {
Hide()
Fullscreen()
UnFullscreen()
OpenDevTools()
CloseDevTools()
// Events
OnWindowEvent(handler func(event WindowEvent))
@ -63,6 +71,7 @@ type PlatformWindow interface {
}
// WindowEvent is emitted by the backend for window state changes.
// Use: evt := window.WindowEvent{Type: "focus", Name: "editor"}
type WindowEvent struct {
Type string // "focus", "blur", "move", "resize", "close"
Name string // window name

View file

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

View file

@ -1,17 +1,23 @@
// pkg/window/service.go
package window
import (
"context"
"fmt"
"strings"
"forge.lthn.ai/core/go/pkg/core"
"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{}
// 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 {
*core.ServiceRuntime[Options]
manager *Manager
@ -19,6 +25,7 @@ type Service struct {
}
// OnStartup queries config from the display orchestrator and registers IPC handlers.
// Use: _ = svc.OnStartup(context.Background())
func (s *Service) OnStartup(ctx context.Context) error {
// Query config — display registers its handler before us (registration order guarantee).
// If display is not registered, handled=false and we skip config.
@ -38,24 +45,25 @@ func (s *Service) OnStartup(ctx context.Context) error {
}
func (s *Service) applyConfig(cfg map[string]any) {
if w, ok := cfg["default_width"]; ok {
if _, ok := w.(int); ok {
// TODO: s.manager.SetDefaultWidth(width) — add when Manager API is extended
if width, ok := cfg["default_width"]; ok {
if width, ok := width.(int); ok {
s.manager.SetDefaultWidth(width)
}
}
if h, ok := cfg["default_height"]; ok {
if _, ok := h.(int); ok {
// TODO: s.manager.SetDefaultHeight(height) — add when Manager API is extended
if height, ok := cfg["default_height"]; ok {
if height, ok := height.(int); ok {
s.manager.SetDefaultHeight(height)
}
}
if sf, ok := cfg["state_file"]; ok {
if _, ok := sf.(string); ok {
// TODO: s.manager.State().SetPath(stateFile) — add when StateManager API is extended
if stateFile, ok := cfg["state_file"]; ok {
if stateFile, ok := stateFile.(string); ok {
s.manager.State().SetPath(stateFile)
}
}
}
// HandleIPCEvents is auto-discovered and registered by core.WithService.
// Use: _ = svc.HandleIPCEvents(core, msg)
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}
@ -68,6 +76,11 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
return s.queryWindowList(), true, nil
case QueryWindowByName:
return s.queryWindowByName(q.Name), true, nil
case QueryWindowBounds:
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:
return s.manager.Layout().ListLayouts(), true, nil
case QueryLayoutGet:
@ -76,6 +89,18 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
return (*Layout)(nil), 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:
return nil, false, nil
}
@ -89,7 +114,14 @@ func (s *Service) queryWindowList() []WindowInfo {
x, y := pw.Position()
w, h := pw.Size()
result = append(result, WindowInfo{
Name: name, Title: pw.Title(), X: x, Y: y, Width: w, Height: h,
Name: name,
Title: pw.Title(),
X: x,
Y: y,
Width: w,
Height: h,
Visible: pw.IsVisible(),
Minimized: pw.IsMinimised(),
Maximized: pw.IsMaximised(),
Focused: pw.IsFocused(),
})
@ -106,7 +138,14 @@ func (s *Service) queryWindowByName(name string) *WindowInfo {
x, y := pw.Position()
w, h := pw.Size()
return &WindowInfo{
Name: name, Title: pw.Title(), X: x, Y: y, Width: w, Height: h,
Name: name,
Title: pw.Title(),
X: x,
Y: y,
Width: w,
Height: h,
Visible: pw.IsVisible(),
Minimized: pw.IsMinimised(),
Maximized: pw.IsMaximised(),
Focused: pw.IsFocused(),
}
@ -123,7 +162,7 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
case TaskSetPosition:
return nil, true, s.taskSetPosition(t.Name, t.X, t.Y)
case TaskSetSize:
return nil, true, s.taskSetSize(t.Name, t.W, t.H)
return nil, true, s.taskSetSize(t.Name, t.Width, t.Height, t.W, t.H)
case TaskMaximise:
return nil, true, s.taskMaximise(t.Name)
case TaskMinimise:
@ -134,6 +173,12 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
return nil, true, s.taskRestore(t.Name)
case TaskSetTitle:
return nil, true, s.taskSetTitle(t.Name, t.Title)
case TaskSetAlwaysOnTop:
return nil, true, s.taskSetAlwaysOnTop(t.Name, t.AlwaysOnTop)
case TaskSetBackgroundColour:
return nil, true, s.taskSetBackgroundColour(t.Name, t.Red, t.Green, t.Blue, t.Alpha)
case TaskSetOpacity:
return nil, true, s.taskSetOpacity(t.Name, t.Opacity)
case TaskSetVisibility:
return nil, true, s.taskSetVisibility(t.Name, t.Visible)
case TaskFullscreen:
@ -149,19 +194,47 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
return nil, true, s.taskTileWindows(t.Mode, t.Windows)
case TaskSnapWindow:
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:
return nil, true, s.taskApplyWorkflow(t.Workflow, t.Windows)
default:
return nil, false, nil
}
}
func (s *Service) taskOpenWindow(t TaskOpenWindow) (any, bool, error) {
pw, err := s.manager.Open(t.Opts...)
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 {
return nil, true, err
}
x, y := pw.Position()
w, h := pw.Size()
info := WindowInfo{Name: pw.Name(), Title: pw.Title(), X: x, Y: y, Width: w, Height: h}
info := WindowInfo{
Name: pw.Name(),
Title: pw.Title(),
X: x,
Y: y,
Width: w,
Height: h,
Visible: pw.IsVisible(),
Minimized: pw.IsMinimised(),
Maximized: pw.IsMaximised(),
Focused: pw.IsFocused(),
}
// Attach platform event listeners that convert to IPC actions
s.trackWindow(pw)
@ -189,7 +262,7 @@ func (s *Service) trackWindow(pw PlatformWindow) {
if data := e.Data; data != nil {
w, _ := data["w"].(int)
h, _ := data["h"].(int)
_ = s.Core().ACTION(ActionWindowResized{Name: e.Name, W: w, H: h})
_ = s.Core().ACTION(ActionWindowResized{Name: e.Name, Width: w, Height: h, W: w, H: h})
}
case "close":
_ = s.Core().ACTION(ActionWindowClosed{Name: e.Name})
@ -213,7 +286,6 @@ func (s *Service) taskCloseWindow(name string) error {
s.manager.State().CaptureState(pw)
pw.Close()
s.manager.Remove(name)
_ = s.Core().ACTION(ActionWindowClosed{Name: name})
return nil
}
@ -227,13 +299,23 @@ func (s *Service) taskSetPosition(name string, x, y int) error {
return nil
}
func (s *Service) taskSetSize(name string, w, h int) error {
func (s *Service) taskSetSize(name string, width, height, fallbackWidth, fallbackHeight int) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
}
pw.SetSize(w, h)
s.manager.State().UpdateSize(name, w, h)
if width == 0 && height == 0 {
width, height = fallbackWidth, fallbackHeight
} else {
if width == 0 {
width = fallbackWidth
}
if height == 0 {
height = fallbackHeight
}
}
pw.SetSize(width, height)
s.manager.State().UpdateSize(name, width, height)
return nil
}
@ -284,6 +366,36 @@ func (s *Service) taskSetTitle(name, title string) error {
return nil
}
func (s *Service) taskSetAlwaysOnTop(name string, alwaysOnTop bool) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
}
pw.SetAlwaysOnTop(alwaysOnTop)
return nil
}
func (s *Service) taskSetBackgroundColour(name string, red, green, blue, alpha uint8) error {
pw, ok := s.manager.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
}
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
}
func (s *Service) taskSetVisibility(name string, visible bool) error {
pw, ok := s.manager.Get(name)
if !ok {
@ -328,10 +440,15 @@ func (s *Service) taskRestoreLayout(name string) error {
if !found {
continue
}
if pw.IsMaximised() || pw.IsMinimised() {
pw.Restore()
}
pw.SetPosition(state.X, state.Y)
pw.SetSize(state.Width, state.Height)
if state.Maximized {
pw.Maximise()
} else {
pw.Restore()
}
}
return nil
@ -353,8 +470,8 @@ func (s *Service) taskTileWindows(mode string, names []string) error {
if len(names) == 0 {
names = s.manager.List()
}
// Default screen size — callers can query screen_primary for actual values.
return s.manager.TileWindows(tm, names, 1920, 1080)
screenW, screenH := s.primaryScreenSize()
return s.manager.TileWindows(tm, names, screenW, screenH)
}
var snapPosMap = map[string]SnapPosition{
@ -370,10 +487,102 @@ func (s *Service) taskSnapWindow(name, position string) error {
if !ok {
return fmt.Errorf("unknown snap position: %s", position)
}
return s.manager.SnapWindow(name, pos, 1920, 1080)
screenW, screenH := s.primaryScreenSize()
return s.manager.SnapWindow(name, pos, screenW, screenH)
}
func (s *Service) taskArrangePair(first, second string) error {
screenW, screenH := s.primaryScreenSize()
return s.manager.ArrangePair(first, second, screenW, screenH)
}
func (s *Service) taskBesideEditor(editorName, windowName string) error {
screenW, screenH := s.primaryScreenSize()
if editorName == "" {
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 {
names = s.manager.List()
}
return s.manager.StackWindows(names, offsetX, offsetY)
}
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.
// Use: mgr := svc.Manager()
func (s *Service) Manager() *Manager {
return s.manager
}

View file

@ -6,6 +6,7 @@ import (
"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"
)
@ -22,12 +23,62 @@ func newTestWindowService(t *testing.T) (*Service, *core.Core) {
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) {
svc, _ := newTestWindowService(t)
assert.NotNil(t, svc)
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) {
_, c := newTestWindowService(t)
result, handled, err := c.PERFORM(TaskOpenWindow{
@ -39,6 +90,18 @@ func TestTaskOpenWindow_Good(t *testing.T) {
assert.Equal(t, "test", info.Name)
}
func TestTaskOpenWindowDescriptor_Good(t *testing.T) {
_, c := newTestWindowService(t)
result, handled, err := c.PERFORM(TaskOpenWindow{
Window: &Window{Name: "descriptor", Title: "Descriptor", Width: 640, Height: 480},
})
require.NoError(t, err)
assert.True(t, handled)
info := result.(WindowInfo)
assert.Equal(t, "descriptor", info.Name)
assert.Equal(t, "Descriptor", info.Title)
}
func TestTaskOpenWindow_Bad(t *testing.T) {
// No window service registered — PERFORM returns handled=false
c, err := core.New(core.WithServiceLock())
@ -51,12 +114,23 @@ func TestQueryWindowList_Good(t *testing.T) {
_, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("a")}})
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("b")}})
_, _, _ = c.PERFORM(TaskMinimise{Name: "b"})
result, handled, err := c.QUERY(QueryWindowList{})
require.NoError(t, err)
assert.True(t, handled)
list := result.([]WindowInfo)
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) {
@ -68,6 +142,8 @@ func TestQueryWindowByName_Good(t *testing.T) {
assert.True(t, handled)
info := result.(*WindowInfo)
assert.Equal(t, "test", info.Name)
assert.True(t, info.Visible)
assert.False(t, info.Minimized)
}
func TestQueryWindowByName_Bad(t *testing.T) {
@ -126,6 +202,178 @@ func TestTaskSetSize_Good(t *testing.T) {
assert.Equal(t, 600, info.Height)
}
func TestTaskMinimiseAndVisibility_Good(t *testing.T) {
_, c := newTestWindowService(t)
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("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")}})

View file

@ -11,6 +11,7 @@ import (
// WindowState holds the persisted position/size of a window.
// 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 {
X int `json:"x,omitempty"`
Y int `json:"y,omitempty"`
@ -23,14 +24,17 @@ type WindowState struct {
}
// StateManager persists window positions to ~/.config/Core/window_state.json.
// Use: sm := window.NewStateManager()
type StateManager struct {
configDir string
statePath string
states map[string]WindowState
mu sync.RWMutex
saveTimer *time.Timer
}
// NewStateManager creates a StateManager loading from the default config directory.
// Use: sm := window.NewStateManager()
func NewStateManager() *StateManager {
sm := &StateManager{
states: make(map[string]WindowState),
@ -45,6 +49,7 @@ func NewStateManager() *StateManager {
// NewStateManagerWithDir creates a StateManager loading from a custom config directory.
// Useful for testing or when the default config directory is not appropriate.
// Use: sm := window.NewStateManagerWithDir(t.TempDir())
func NewStateManagerWithDir(configDir string) *StateManager {
sm := &StateManager{
configDir: configDir,
@ -55,11 +60,38 @@ func NewStateManagerWithDir(configDir string) *StateManager {
}
func (sm *StateManager) filePath() string {
if sm.statePath != "" {
return sm.statePath
}
return filepath.Join(sm.configDir, "window_state.json")
}
func (sm *StateManager) dataDir() string {
if sm.statePath != "" {
return filepath.Dir(sm.statePath)
}
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) {
if path == "" {
return
}
sm.mu.Lock()
if sm.saveTimer != nil {
sm.saveTimer.Stop()
sm.saveTimer = nil
}
sm.statePath = path
sm.states = make(map[string]WindowState)
sm.mu.Unlock()
sm.load()
}
func (sm *StateManager) load() {
if sm.configDir == "" {
if sm.configDir == "" && sm.statePath == "" {
return
}
data, err := os.ReadFile(sm.filePath())
@ -72,7 +104,7 @@ func (sm *StateManager) load() {
}
func (sm *StateManager) save() {
if sm.configDir == "" {
if sm.configDir == "" && sm.statePath == "" {
return
}
sm.mu.RLock()
@ -81,7 +113,7 @@ func (sm *StateManager) save() {
if err != nil {
return
}
_ = os.MkdirAll(sm.configDir, 0o755)
_ = os.MkdirAll(sm.dataDir(), 0o755)
_ = os.WriteFile(sm.filePath(), data, 0o644)
}
@ -93,6 +125,7 @@ func (sm *StateManager) scheduleSave() {
}
// GetState returns the saved state for a window name.
// Use: state, ok := sm.GetState("editor")
func (sm *StateManager) GetState(name string) (WindowState, bool) {
sm.mu.RLock()
defer sm.mu.RUnlock()
@ -101,6 +134,7 @@ func (sm *StateManager) GetState(name string) (WindowState, bool) {
}
// 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) {
state.UpdatedAt = time.Now().UnixMilli()
sm.mu.Lock()
@ -110,6 +144,7 @@ func (sm *StateManager) SetState(name string, state WindowState) {
}
// UpdatePosition updates only the position fields.
// Use: sm.UpdatePosition("editor", 160, 120)
func (sm *StateManager) UpdatePosition(name string, x, y int) {
sm.mu.Lock()
s := sm.states[name]
@ -122,6 +157,7 @@ func (sm *StateManager) UpdatePosition(name string, x, y int) {
}
// UpdateSize updates only the size fields.
// Use: sm.UpdateSize("editor", 1280, 800)
func (sm *StateManager) UpdateSize(name string, width, height int) {
sm.mu.Lock()
s := sm.states[name]
@ -134,6 +170,7 @@ func (sm *StateManager) UpdateSize(name string, width, height int) {
}
// UpdateMaximized updates the maximized flag.
// Use: sm.UpdateMaximized("editor", true)
func (sm *StateManager) UpdateMaximized(name string, maximized bool) {
sm.mu.Lock()
s := sm.states[name]
@ -145,6 +182,7 @@ func (sm *StateManager) UpdateMaximized(name string, maximized bool) {
}
// CaptureState snapshots the current state from a PlatformWindow.
// Use: sm.CaptureState(pw)
func (sm *StateManager) CaptureState(pw PlatformWindow) {
x, y := pw.Position()
w, h := pw.Size()
@ -155,6 +193,7 @@ func (sm *StateManager) CaptureState(pw PlatformWindow) {
}
// ApplyState restores saved position/size to a Window descriptor.
// Use: sm.ApplyState(&window.Window{Name: "editor"})
func (sm *StateManager) ApplyState(w *Window) {
s, ok := sm.GetState(w.Name)
if !ok {
@ -171,6 +210,7 @@ func (sm *StateManager) ApplyState(w *Window) {
}
// ListStates returns all stored window names.
// Use: names := sm.ListStates()
func (sm *StateManager) ListStates() []string {
sm.mu.RLock()
defer sm.mu.RUnlock()
@ -182,6 +222,7 @@ func (sm *StateManager) ListStates() []string {
}
// Clear removes all stored states.
// Use: sm.Clear()
func (sm *StateManager) Clear() {
sm.mu.Lock()
sm.states = make(map[string]WindowState)
@ -190,6 +231,7 @@ func (sm *StateManager) Clear() {
}
// ForceSync writes state to disk immediately.
// Use: sm.ForceSync()
func (sm *StateManager) ForceSync() {
if sm.saveTimer != nil {
sm.saveTimer.Stop()

View file

@ -3,7 +3,20 @@ package window
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.
// Use: mode := window.TileModeLeftRight
type TileMode int
const (
@ -27,9 +40,12 @@ var tileModeNames = map[TileMode]string{
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] }
// SnapPosition defines where a window snaps to.
// Use: pos := window.SnapRight
type SnapPosition int
const (
@ -45,6 +61,7 @@ const (
)
// WorkflowLayout is a predefined arrangement for common tasks.
// Use: workflow := window.WorkflowCoding
type WorkflowLayout int
const (
@ -59,8 +76,21 @@ var workflowNames = map[WorkflowLayout]string{
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] }
// ParseWorkflowLayout converts a workflow name into its enum value.
// Use: workflow, ok := window.ParseWorkflowLayout("coding")
func ParseWorkflowLayout(name string) (WorkflowLayout, bool) {
for workflow, workflowName := range workflowNames {
if workflowName == name {
return workflow, true
}
}
return WorkflowCoding, false
}
// TileWindows arranges the named windows in the given mode across the screen area.
func (m *Manager) TileWindows(mode TileMode, names []string, screenW, screenH int) error {
windows := make([]PlatformWindow, 0, len(names))
@ -81,6 +111,7 @@ func (m *Manager) TileWindows(mode TileMode, names []string, screenW, screenH in
case TileModeLeftRight:
w := screenW / len(windows)
for i, pw := range windows {
normalizeWindowForLayout(pw)
pw.SetPosition(i*w, 0)
pw.SetSize(w, screenH)
}
@ -91,6 +122,7 @@ func (m *Manager) TileWindows(mode TileMode, names []string, screenW, screenH in
}
cellW := screenW / cols
for i, pw := range windows {
normalizeWindowForLayout(pw)
row := i / cols
col := i % cols
rows := (len(windows) + cols - 1) / cols
@ -100,41 +132,49 @@ func (m *Manager) TileWindows(mode TileMode, names []string, screenW, screenH in
}
case TileModeLeftHalf:
for _, pw := range windows {
normalizeWindowForLayout(pw)
pw.SetPosition(0, 0)
pw.SetSize(halfW, screenH)
}
case TileModeRightHalf:
for _, pw := range windows {
normalizeWindowForLayout(pw)
pw.SetPosition(halfW, 0)
pw.SetSize(halfW, screenH)
}
case TileModeTopHalf:
for _, pw := range windows {
normalizeWindowForLayout(pw)
pw.SetPosition(0, 0)
pw.SetSize(screenW, halfH)
}
case TileModeBottomHalf:
for _, pw := range windows {
normalizeWindowForLayout(pw)
pw.SetPosition(0, halfH)
pw.SetSize(screenW, halfH)
}
case TileModeTopLeft:
for _, pw := range windows {
normalizeWindowForLayout(pw)
pw.SetPosition(0, 0)
pw.SetSize(halfW, halfH)
}
case TileModeTopRight:
for _, pw := range windows {
normalizeWindowForLayout(pw)
pw.SetPosition(halfW, 0)
pw.SetSize(halfW, halfH)
}
case TileModeBottomLeft:
for _, pw := range windows {
normalizeWindowForLayout(pw)
pw.SetPosition(0, halfH)
pw.SetSize(halfW, halfH)
}
case TileModeBottomRight:
for _, pw := range windows {
normalizeWindowForLayout(pw)
pw.SetPosition(halfW, halfH)
pw.SetSize(halfW, halfH)
}
@ -153,30 +193,39 @@ func (m *Manager) SnapWindow(name string, pos SnapPosition, screenW, screenH int
switch pos {
case SnapLeft:
normalizeWindowForLayout(pw)
pw.SetPosition(0, 0)
pw.SetSize(halfW, screenH)
case SnapRight:
normalizeWindowForLayout(pw)
pw.SetPosition(halfW, 0)
pw.SetSize(halfW, screenH)
case SnapTop:
normalizeWindowForLayout(pw)
pw.SetPosition(0, 0)
pw.SetSize(screenW, halfH)
case SnapBottom:
normalizeWindowForLayout(pw)
pw.SetPosition(0, halfH)
pw.SetSize(screenW, halfH)
case SnapTopLeft:
normalizeWindowForLayout(pw)
pw.SetPosition(0, 0)
pw.SetSize(halfW, halfH)
case SnapTopRight:
normalizeWindowForLayout(pw)
pw.SetPosition(halfW, 0)
pw.SetSize(halfW, halfH)
case SnapBottomLeft:
normalizeWindowForLayout(pw)
pw.SetPosition(0, halfH)
pw.SetSize(halfW, halfH)
case SnapBottomRight:
normalizeWindowForLayout(pw)
pw.SetPosition(halfW, halfH)
pw.SetSize(halfW, halfH)
case SnapCenter:
normalizeWindowForLayout(pw)
cw, ch := pw.Size()
pw.SetPosition((screenW-cw)/2, (screenH-ch)/2)
}
@ -190,6 +239,7 @@ func (m *Manager) StackWindows(names []string, offsetX, offsetY int) error {
if !ok {
return fmt.Errorf("window %q not found", name)
}
normalizeWindowForLayout(pw)
pw.SetPosition(i*offsetX, i*offsetY)
}
return nil
@ -206,11 +256,13 @@ func (m *Manager) ApplyWorkflow(workflow WorkflowLayout, names []string, screenW
// 70/30 split — main editor + terminal
mainW := screenW * 70 / 100
if pw, ok := m.Get(names[0]); ok {
normalizeWindowForLayout(pw)
pw.SetPosition(0, 0)
pw.SetSize(mainW, screenH)
}
if len(names) > 1 {
if pw, ok := m.Get(names[1]); ok {
normalizeWindowForLayout(pw)
pw.SetPosition(mainW, 0)
pw.SetSize(screenW-mainW, screenH)
}
@ -219,11 +271,13 @@ func (m *Manager) ApplyWorkflow(workflow WorkflowLayout, names []string, screenW
// 60/40 split
mainW := screenW * 60 / 100
if pw, ok := m.Get(names[0]); ok {
normalizeWindowForLayout(pw)
pw.SetPosition(0, 0)
pw.SetSize(mainW, screenH)
}
if len(names) > 1 {
if pw, ok := m.Get(names[1]); ok {
normalizeWindowForLayout(pw)
pw.SetPosition(mainW, 0)
pw.SetSize(screenW-mainW, screenH)
}
@ -231,6 +285,7 @@ func (m *Manager) ApplyWorkflow(workflow WorkflowLayout, names []string, screenW
case WorkflowPresenting:
// Maximise first window
if pw, ok := m.Get(names[0]); ok {
normalizeWindowForLayout(pw)
pw.SetPosition(0, 0)
pw.SetSize(screenW, screenH)
}

View file

@ -7,45 +7,51 @@ import (
)
// WailsPlatform implements Platform using Wails v3.
// Use: platform := window.NewWailsPlatform(app)
type WailsPlatform struct {
app *application.App
}
// NewWailsPlatform creates a Wails-backed Platform.
// Use: platform := window.NewWailsPlatform(app)
func NewWailsPlatform(app *application.App) *WailsPlatform {
return &WailsPlatform{app: app}
}
// CreateWindow opens a new Wails window from platform options.
// Use: w := wp.CreateWindow(window.PlatformWindowOptions{Name: "editor", URL: "/editor"})
func (wp *WailsPlatform) CreateWindow(opts PlatformWindowOptions) PlatformWindow {
wOpts := application.WebviewWindowOptions{
Name: opts.Name,
Title: opts.Title,
URL: opts.URL,
Width: opts.Width,
Height: opts.Height,
X: opts.X,
Y: opts.Y,
MinWidth: opts.MinWidth,
MinHeight: opts.MinHeight,
MaxWidth: opts.MaxWidth,
MaxHeight: opts.MaxHeight,
Frameless: opts.Frameless,
Hidden: opts.Hidden,
AlwaysOnTop: opts.AlwaysOnTop,
DisableResize: opts.DisableResize,
EnableFileDrop: opts.EnableFileDrop,
BackgroundColour: application.NewRGBA(opts.BackgroundColour[0], opts.BackgroundColour[1], opts.BackgroundColour[2], opts.BackgroundColour[3]),
Name: opts.Name,
Title: opts.Title,
URL: opts.URL,
Width: opts.Width,
Height: opts.Height,
X: opts.X,
Y: opts.Y,
MinWidth: opts.MinWidth,
MinHeight: opts.MinHeight,
MaxWidth: opts.MaxWidth,
MaxHeight: opts.MaxHeight,
Frameless: opts.Frameless,
Hidden: opts.Hidden,
AlwaysOnTop: opts.AlwaysOnTop,
DisableResize: opts.DisableResize,
EnableFileDrop: opts.EnableFileDrop,
BackgroundColour: application.NewRGBA(opts.BackgroundColour[0], opts.BackgroundColour[1], opts.BackgroundColour[2], opts.BackgroundColour[3]),
}
w := wp.app.Window.NewWithOptions(wOpts)
return &wailsWindow{w: w, title: opts.Title}
}
// GetWindows returns the live Wails windows.
// Use: windows := wp.GetWindows()
func (wp *WailsPlatform) GetWindows() []PlatformWindow {
all := wp.app.Window.GetAll()
out := make([]PlatformWindow, 0, len(all))
for _, w := range all {
if wv, ok := w.(*application.WebviewWindow); ok {
out = append(out, &wailsWindow{w: wv})
out = append(out, &wailsWindow{w: wv, title: wv.Name()})
}
}
return out
@ -58,18 +64,29 @@ type wailsWindow struct {
title string
}
func (ww *wailsWindow) Name() string { return ww.w.Name() }
func (ww *wailsWindow) Title() string { return ww.title }
func (ww *wailsWindow) Position() (int, int) { return ww.w.Position() }
func (ww *wailsWindow) Size() (int, int) { return ww.w.Size() }
func (ww *wailsWindow) IsMaximised() bool { return ww.w.IsMaximised() }
func (ww *wailsWindow) IsFocused() bool { return ww.w.IsFocused() }
func (ww *wailsWindow) SetTitle(title string) { ww.title = title; ww.w.SetTitle(title) }
func (ww *wailsWindow) SetPosition(x, y int) { ww.w.SetPosition(x, y) }
func (ww *wailsWindow) Name() string { return ww.w.Name() }
func (ww *wailsWindow) Title() string {
if ww.title != "" {
return ww.title
}
if ww.w != nil {
return ww.w.Name()
}
return ""
}
func (ww *wailsWindow) Position() (int, int) { return ww.w.Position() }
func (ww *wailsWindow) Size() (int, int) { return ww.w.Size() }
func (ww *wailsWindow) IsVisible() bool { return ww.w.IsVisible() }
func (ww *wailsWindow) IsMinimised() bool { return ww.w.IsMinimised() }
func (ww *wailsWindow) IsMaximised() bool { return ww.w.IsMaximised() }
func (ww *wailsWindow) IsFocused() bool { return ww.w.IsFocused() }
func (ww *wailsWindow) SetTitle(title string) { ww.title = title; ww.w.SetTitle(title) }
func (ww *wailsWindow) SetPosition(x, y int) { ww.w.SetPosition(x, y) }
func (ww *wailsWindow) SetSize(width, height int) { ww.w.SetSize(width, height) }
func (ww *wailsWindow) SetBackgroundColour(r, g, b, a uint8) {
ww.w.SetBackgroundColour(application.NewRGBA(r, g, b, a))
}
func (ww *wailsWindow) SetOpacity(opacity float32) { ww.w.SetOpacity(opacity) }
func (ww *wailsWindow) SetVisibility(visible bool) {
if visible {
ww.w.Show()
@ -87,6 +104,8 @@ func (ww *wailsWindow) Show() { ww.w.Show() }
func (ww *wailsWindow) Hide() { ww.w.Hide() }
func (ww *wailsWindow) Fullscreen() { ww.w.Fullscreen() }
func (ww *wailsWindow) UnFullscreen() { ww.w.UnFullscreen() }
func (ww *wailsWindow) OpenDevTools() { ww.w.OpenDevTools() }
func (ww *wailsWindow) CloseDevTools() { ww.w.CloseDevTools() }
func (ww *wailsWindow) OnWindowEvent(handler func(event WindowEvent)) {
name := ww.w.Name()
@ -111,8 +130,8 @@ func (ww *wailsWindow) OnWindowEvent(handler func(event WindowEvent)) {
data["y"] = y
case "resize":
w, h := ww.w.Size()
data["width"] = w
data["height"] = h
data["w"] = w
data["h"] = h
}
handler(WindowEvent{
Type: typeName,
@ -140,4 +159,3 @@ var _ PlatformWindow = (*wailsWindow)(nil)
// Ensure WailsPlatform satisfies Platform at compile time.
var _ Platform = (*WailsPlatform)(nil)

View file

@ -3,27 +3,30 @@ package window
import (
"fmt"
"math"
"sync"
)
// Window is CoreGUI's own window descriptor — NOT a Wails type alias.
// Use: spec := &window.Window{Name: "editor", URL: "/editor"}
type Window struct {
Name string
Title string
URL string
Width, Height int
X, Y int
Name string
Title string
URL string
Width, Height int
X, Y int
MinWidth, MinHeight int
MaxWidth, MaxHeight int
Frameless bool
Hidden bool
AlwaysOnTop bool
BackgroundColour [4]uint8
DisableResize bool
EnableFileDrop bool
Frameless bool
Hidden bool
AlwaysOnTop bool
BackgroundColour [4]uint8
DisableResize bool
EnableFileDrop bool
}
// ToPlatformOptions converts a Window to PlatformWindowOptions for the backend.
// Use: opts := spec.ToPlatformOptions()
func (w *Window) ToPlatformOptions() PlatformWindowOptions {
return PlatformWindowOptions{
Name: w.Name, Title: w.Title, URL: w.URL,
@ -37,15 +40,19 @@ func (w *Window) ToPlatformOptions() PlatformWindowOptions {
}
// Manager manages window lifecycle through a Platform backend.
// Use: mgr := window.NewManager(platform)
type Manager struct {
platform Platform
state *StateManager
layout *LayoutManager
windows map[string]PlatformWindow
mu sync.RWMutex
platform Platform
state *StateManager
layout *LayoutManager
windows map[string]PlatformWindow
defaultWidth int
defaultHeight int
mu sync.RWMutex
}
// NewManager creates a window Manager with the given platform backend.
// Use: mgr := window.NewManager(platform)
func NewManager(platform Platform) *Manager {
return &Manager{
platform: platform,
@ -56,7 +63,7 @@ func NewManager(platform Platform) *Manager {
}
// 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 {
return &Manager{
platform: platform,
@ -66,7 +73,24 @@ 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) {
if width > 0 {
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) {
if height > 0 {
m.defaultHeight = height
}
}
// Open creates a window using functional options, applies saved state, and tracks it.
// Use: _, err := mgr.Open(window.WithName("editor"), window.WithURL("/editor"))
func (m *Manager) Open(opts ...WindowOption) (PlatformWindow, error) {
w, err := ApplyOptions(opts...)
if err != nil {
@ -76,6 +100,7 @@ func (m *Manager) Open(opts ...WindowOption) (PlatformWindow, error) {
}
// 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 == "" {
w.Name = "main"
@ -84,10 +109,18 @@ func (m *Manager) Create(w *Window) (PlatformWindow, error) {
w.Title = "Core"
}
if w.Width == 0 {
w.Width = 1280
if m.defaultWidth > 0 {
w.Width = m.defaultWidth
} else {
w.Width = 1280
}
}
if w.Height == 0 {
w.Height = 800
if m.defaultHeight > 0 {
w.Height = m.defaultHeight
} else {
w.Height = 800
}
}
if w.URL == "" {
w.URL = "/"
@ -106,6 +139,7 @@ func (m *Manager) Create(w *Window) (PlatformWindow, error) {
}
// Get returns a tracked window by name.
// Use: pw, ok := mgr.Get("editor")
func (m *Manager) Get(name string) (PlatformWindow, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
@ -114,6 +148,7 @@ func (m *Manager) Get(name string) (PlatformWindow, bool) {
}
// List returns all tracked window names.
// Use: names := mgr.List()
func (m *Manager) List() []string {
m.mu.RLock()
defer m.mu.RUnlock()
@ -125,6 +160,7 @@ func (m *Manager) List() []string {
}
// Remove stops tracking a window by name.
// Use: mgr.Remove("editor")
func (m *Manager) Remove(name string) {
m.mu.Lock()
delete(m.windows, name)
@ -132,16 +168,171 @@ func (m *Manager) Remove(name string) {
}
// Platform returns the underlying platform for direct access.
// Use: platform := mgr.Platform()
func (m *Manager) Platform() Platform {
return m.platform
}
// State returns the state manager for window persistence.
// Use: state := mgr.State()
func (m *Manager) State() *StateManager {
return m.state
}
// Layout returns the layout manager.
// Use: layouts := mgr.Layout()
func (m *Manager) Layout() *LayoutManager {
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
}

View file

@ -2,10 +2,12 @@
package window
import (
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/wailsapp/wails/v3/pkg/application"
)
func TestWindowDefaults(t *testing.T) {
@ -110,6 +112,19 @@ func TestManager_Open_Defaults_Good(t *testing.T) {
assert.Equal(t, 800, h)
}
func TestManager_DefaultSizeOverrides_Good(t *testing.T) {
m, _ := newTestManager()
m.SetDefaultWidth(1440)
m.SetDefaultHeight(900)
pw, err := m.Open()
require.NoError(t, err)
w, h := pw.Size()
assert.Equal(t, 1440, w)
assert.Equal(t, 900, h)
}
func TestManager_Open_Bad(t *testing.T) {
m, _ := newTestManager()
_, err := m.Open(func(w *Window) error { return assert.AnError })
@ -148,6 +163,33 @@ func TestManager_Remove_Good(t *testing.T) {
assert.False(t, ok)
}
func TestWailsWindow_DevToolsToggle_Good(t *testing.T) {
app := application.NewApp()
platform := NewWailsPlatform(app)
pw := platform.CreateWindow(PlatformWindowOptions{Name: "devtools"})
ww, ok := pw.(*wailsWindow)
require.True(t, ok)
ww.OpenDevTools()
assert.True(t, ww.w.DevToolsOpen())
ww.CloseDevTools()
assert.False(t, ww.w.DevToolsOpen())
}
func TestWailsPlatform_GetWindows_TitleFallback_Good(t *testing.T) {
app := application.NewApp()
platform := NewWailsPlatform(app)
pw := platform.CreateWindow(PlatformWindowOptions{Name: "fallback"})
require.NotNil(t, pw)
windows := platform.GetWindows()
require.Len(t, windows, 1)
assert.Equal(t, "fallback", windows[0].Title())
}
// --- StateManager Tests ---
// newTestStateManager creates a clean StateManager with a temp dir for testing.
@ -226,6 +268,23 @@ func TestStateManager_Persistence_Good(t *testing.T) {
assert.Equal(t, 500, got.Width)
}
func TestStateManager_SetPath_Good(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "custom-window-state.json")
sm := &StateManager{states: make(map[string]WindowState)}
sm.SetPath(path)
sm.SetState("custom", WindowState{X: 11, Y: 22, Width: 333, Height: 444})
sm.ForceSync()
reloaded := &StateManager{states: make(map[string]WindowState)}
reloaded.SetPath(path)
got, ok := reloaded.GetState("custom")
require.True(t, ok)
assert.Equal(t, 11, got.X)
assert.Equal(t, 333, got.Width)
}
// --- LayoutManager Tests ---
// newTestLayoutManager creates a clean LayoutManager with a temp dir for testing.
@ -328,3 +387,43 @@ func TestWorkflowLayout_Good(t *testing.T) {
assert.Equal(t, "coding", WorkflowCoding.String())
assert.Equal(t, "debugging", WorkflowDebugging.String())
}
func TestManager_SuggestLayout_Good(t *testing.T) {
m, _ := newTestManager()
suggestion := m.SuggestLayout(1920, 1080, 3)
assert.Equal(t, "quadrants", suggestion.Mode)
assert.Equal(t, 2, suggestion.Columns)
}
func TestManager_FindSpace_Good(t *testing.T) {
m, _ := newTestManager()
_, _ = m.Open(WithName("one"), WithPosition(0, 0), WithSize(800, 600))
space := m.FindSpace(1920, 1080, 400, 300)
assert.GreaterOrEqual(t, space.X, 0)
assert.GreaterOrEqual(t, space.Y, 0)
}
func TestManager_ArrangePair_Good(t *testing.T) {
m, _ := newTestManager()
_, _ = m.Open(WithName("left"), WithSize(800, 600))
_, _ = m.Open(WithName("right"), WithSize(800, 600))
err := m.ArrangePair("left", "right", 1920, 1080)
require.NoError(t, err)
left, _ := m.Get("left")
x, _ := left.Position()
assert.Equal(t, 0, x)
}
func TestManager_BesideEditor_Good(t *testing.T) {
m, _ := newTestManager()
_, _ = m.Open(WithName("editor"))
_, _ = m.Open(WithName("assistant"))
err := m.BesideEditor("editor", "assistant", 1920, 1080)
require.NoError(t, err)
editor, _ := m.Get("editor")
assistant, _ := m.Get("assistant")
ex, _ := editor.Size()
ax, _ := assistant.Position()
assert.Greater(t, ex, 0)
assert.Greater(t, ax, 0)
}

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

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

View file

@ -0,0 +1,366 @@
package application
import (
"sync"
"github.com/wailsapp/wails/v3/pkg/events"
)
// RGBA represents a colour.
type RGBA struct {
R, G, B, A uint8
}
// NewRGBA creates a colour value.
func NewRGBA(r, g, b, a uint8) RGBA { return RGBA{R: r, G: g, B: b, A: a} }
// Logger is a minimal logger used by the repo.
type Logger struct{}
func (l *Logger) Info(message string, args ...any) {}
// Context carries event data.
type Context struct {
droppedFiles []string
dropTargetData *DropTargetDetails
}
func (c *Context) DroppedFiles() []string {
if c == nil {
return nil
}
out := make([]string, len(c.droppedFiles))
copy(out, c.droppedFiles)
return out
}
func (c *Context) DropTargetDetails() *DropTargetDetails {
if c == nil || c.dropTargetData == nil {
return nil
}
d := *c.dropTargetData
return &d
}
// DropTargetDetails describes the drop target.
type DropTargetDetails struct {
ElementID string
}
// WindowEvent wraps window event context.
type WindowEvent struct {
ctx *Context
}
func (e *WindowEvent) Context() *Context {
if e == nil {
return nil
}
if e.ctx == nil {
e.ctx = &Context{}
}
return e.ctx
}
// WebviewWindowOptions configures a new window.
type WebviewWindowOptions struct {
Name string
Title string
URL string
Width int
Height int
X int
Y int
MinWidth int
MinHeight int
MaxWidth int
MaxHeight int
Frameless bool
Hidden bool
AlwaysOnTop bool
DisableResize bool
EnableFileDrop bool
BackgroundColour RGBA
}
// WebviewWindow is a lightweight in-memory window handle.
type WebviewWindow struct {
opts WebviewWindowOptions
title string
x, y int
width, height int
minimised bool
maximised bool
focused bool
visible bool
alwaysOnTop bool
fullscreen bool
devtoolsOpen bool
eventHandlers map[events.WindowEventType][]func(*WindowEvent)
mu sync.Mutex
}
func newWebviewWindow(opts WebviewWindowOptions) *WebviewWindow {
return &WebviewWindow{
opts: opts,
title: opts.Title,
x: opts.X,
y: opts.Y,
width: opts.Width,
height: opts.Height,
visible: !opts.Hidden,
alwaysOnTop: opts.AlwaysOnTop,
eventHandlers: make(map[events.WindowEventType][]func(*WindowEvent)),
}
}
func (w *WebviewWindow) Name() string { return w.opts.Name }
func (w *WebviewWindow) Position() (int, int) { return w.x, w.y }
func (w *WebviewWindow) Size() (int, int) { return w.width, w.height }
func (w *WebviewWindow) IsVisible() bool { return w.visible }
func (w *WebviewWindow) IsMinimised() bool { return w.minimised }
func (w *WebviewWindow) IsMaximised() bool { return w.maximised }
func (w *WebviewWindow) IsFocused() bool { return w.focused }
func (w *WebviewWindow) SetTitle(title string) { w.title = title }
func (w *WebviewWindow) SetPosition(x, y int) { w.x, w.y = x, y }
func (w *WebviewWindow) SetSize(width, height int) {
w.width, w.height = width, height
}
func (w *WebviewWindow) SetBackgroundColour(colour RGBA) {}
func (w *WebviewWindow) SetOpacity(opacity float32) {}
func (w *WebviewWindow) SetVisibility(visible bool) { w.visible = visible }
func (w *WebviewWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop }
func (w *WebviewWindow) Maximise() { w.maximised = true; w.minimised = false; w.visible = true }
func (w *WebviewWindow) Restore() { w.maximised = false; w.minimised = false; w.visible = true }
func (w *WebviewWindow) Minimise() { w.minimised = true; w.maximised = false; w.visible = false }
func (w *WebviewWindow) Focus() { w.focused = true }
func (w *WebviewWindow) Close() {}
func (w *WebviewWindow) Show() { w.visible = true }
func (w *WebviewWindow) Hide() { w.visible = false }
func (w *WebviewWindow) Fullscreen() { w.fullscreen = true }
func (w *WebviewWindow) UnFullscreen() { w.fullscreen = false }
func (w *WebviewWindow) OpenDevTools() { w.devtoolsOpen = true }
func (w *WebviewWindow) CloseDevTools() { w.devtoolsOpen = false }
func (w *WebviewWindow) DevToolsOpen() bool { return w.devtoolsOpen }
func (w *WebviewWindow) OnWindowEvent(eventType events.WindowEventType, callback func(event *WindowEvent)) func() {
w.mu.Lock()
defer w.mu.Unlock()
w.eventHandlers[eventType] = append(w.eventHandlers[eventType], callback)
return func() {
w.mu.Lock()
defer w.mu.Unlock()
handlers := w.eventHandlers[eventType]
if len(handlers) == 0 {
return
}
w.eventHandlers[eventType] = handlers[:len(handlers)-1]
}
}
func (w *WebviewWindow) trigger(eventType events.WindowEventType, event *WindowEvent) {
w.mu.Lock()
handlers := append([]func(*WindowEvent){}, w.eventHandlers[eventType]...)
w.mu.Unlock()
for _, handler := range handlers {
handler(event)
}
}
// WindowManager manages in-memory windows.
type WindowManager struct {
windows []*WebviewWindow
}
func (wm *WindowManager) NewWithOptions(opts WebviewWindowOptions) *WebviewWindow {
w := newWebviewWindow(opts)
wm.windows = append(wm.windows, w)
return w
}
func (wm *WindowManager) GetAll() []any {
out := make([]any, len(wm.windows))
for i, w := range wm.windows {
out[i] = w
}
return out
}
// Menu role constants.
type MenuRole int
const (
AppMenu MenuRole = iota
FileMenu
EditMenu
ViewMenu
WindowMenu
HelpMenu
)
// Menu is a lightweight in-memory menu.
type Menu struct {
items []*MenuItem
}
func NewMenu() *Menu { return &Menu{} }
func (m *Menu) Add(label string) *MenuItem {
item := &MenuItem{label: label}
m.items = append(m.items, item)
return item
}
func (m *Menu) AddSeparator() {}
func (m *Menu) AddSubmenu(label string) *Menu {
return &Menu{}
}
func (m *Menu) AddRole(role MenuRole) {}
func (m *Menu) SetApplicationMenu(menu *Menu) {}
// MenuItem is a lightweight menu item.
type MenuItem struct {
label string
accelerator string
tooltip string
checked bool
enabled bool
onClick func(*Context)
}
func (mi *MenuItem) SetAccelerator(accel string) *MenuItem {
mi.accelerator = accel
return mi
}
func (mi *MenuItem) SetTooltip(text string) *MenuItem {
mi.tooltip = text
return mi
}
func (mi *MenuItem) SetChecked(checked bool) *MenuItem {
mi.checked = checked
return mi
}
func (mi *MenuItem) SetEnabled(enabled bool) *MenuItem {
mi.enabled = enabled
return mi
}
func (mi *MenuItem) OnClick(fn func(ctx *Context)) *MenuItem {
mi.onClick = fn
return mi
}
// SystemTray models a tray icon.
type SystemTray struct {
icon []byte
templateIcon []byte
tooltip string
label string
menu *Menu
attachedWindow interface {
Show()
Hide()
Focus()
IsVisible() bool
}
onClick func()
}
func (st *SystemTray) SetIcon(icon []byte) *SystemTray {
st.icon = append([]byte(nil), icon...)
return st
}
func (st *SystemTray) SetTemplateIcon(icon []byte) *SystemTray {
st.templateIcon = append([]byte(nil), icon...)
return st
}
func (st *SystemTray) SetTooltip(tooltip string) {
st.tooltip = tooltip
}
func (st *SystemTray) SetLabel(label string) {
st.label = label
}
func (st *SystemTray) SetMenu(menu *Menu) *SystemTray {
st.menu = menu
return st
}
func (st *SystemTray) Show() {}
func (st *SystemTray) Hide() {}
func (st *SystemTray) OnClick(callback func()) *SystemTray {
st.onClick = callback
return st
}
func (st *SystemTray) AttachWindow(window interface {
Show()
Hide()
Focus()
IsVisible() bool
}) *SystemTray {
st.attachedWindow = window
st.OnClick(func() {
if st.attachedWindow == nil {
return
}
if st.attachedWindow.IsVisible() {
st.attachedWindow.Hide()
return
}
st.attachedWindow.Show()
st.attachedWindow.Focus()
})
return st
}
func (st *SystemTray) Click() {
if st.onClick != nil {
st.onClick()
}
}
// SystemTrayManager creates trays.
type SystemTrayManager struct {
app *App
}
func (stm *SystemTrayManager) New() *SystemTray { return &SystemTray{} }
// MenuManager manages application menus.
type MenuManager struct {
appMenu *Menu
}
func (mm *MenuManager) SetApplicationMenu(menu *Menu) { mm.appMenu = menu }
// App is the top-level application container.
type App struct {
Window *WindowManager
Menu *MenuManager
SystemTray *SystemTrayManager
Logger *Logger
quit bool
}
func NewApp() *App {
app := &App{}
app.Window = &WindowManager{}
app.Menu = &MenuManager{}
app.SystemTray = &SystemTrayManager{app: app}
app.Logger = &Logger{}
return app
}
func (a *App) Quit() { a.quit = true }
func (a *App) NewMenu() *Menu { return NewMenu() }

View file

@ -0,0 +1,21 @@
package events
// WindowEventType identifies a window event.
type WindowEventType string
// Common exposes the event names used by the repo.
var Common = struct {
WindowFocus WindowEventType
WindowLostFocus WindowEventType
WindowDidMove WindowEventType
WindowDidResize WindowEventType
WindowClosing WindowEventType
WindowFilesDropped WindowEventType
}{
WindowFocus: "focus",
WindowLostFocus: "blur",
WindowDidMove: "move",
WindowDidResize: "resize",
WindowClosing: "close",
WindowFilesDropped: "files-dropped",
}

View file

@ -0,0 +1 @@
../baseline-browser-mapping/dist/cli.js

View file

@ -0,0 +1 @@
../browserslist/cli.js

View file

@ -0,0 +1 @@
../lmdb/bin/download-prebuilds.js

View file

@ -0,0 +1 @@
../msgpackr-extract/bin/download-prebuilds.js

View file

@ -0,0 +1 @@
../esbuild/bin/esbuild

View file

@ -0,0 +1 @@
../@npmcli/installed-package-contents/bin/index.js

View file

@ -0,0 +1 @@
../jsesc/bin/jsesc

View file

@ -0,0 +1 @@
../json5/lib/cli.js

View file

@ -0,0 +1 @@
../karma/bin/karma

View file

@ -0,0 +1 @@
../mime/cli.js

View file

@ -0,0 +1 @@
../mkdirp/bin/cmd.js

View file

@ -0,0 +1 @@
../nanoid/bin/nanoid.cjs

1
ui/node_modules.bak/.bin/ng Symbolic link
View file

@ -0,0 +1 @@
../@angular/cli/bin/ng.js

View file

@ -0,0 +1 @@
../@angular/compiler-cli/bundles/src/bin/ng_xi18n.js

View file

@ -0,0 +1 @@
../@angular/compiler-cli/bundles/src/bin/ngc.js

View file

@ -0,0 +1 @@
../node-gyp/bin/node-gyp.js

View file

@ -0,0 +1 @@
../node-gyp-build-optional-packages/bin.js

View file

@ -0,0 +1 @@
../node-gyp-build-optional-packages/optional.js

View file

@ -0,0 +1 @@
../node-gyp-build-optional-packages/build-test.js

View file

@ -0,0 +1 @@
../which/bin/node-which

View file

@ -0,0 +1 @@
../nopt/bin/nopt.js

View file

@ -0,0 +1 @@
../pacote/bin/index.js

View file

@ -0,0 +1 @@
../@babel/parser/bin/babel-parser.js

View file

@ -0,0 +1 @@
../resolve/bin/resolve

View file

@ -0,0 +1 @@
../rimraf/bin.js

View file

@ -0,0 +1 @@
../rollup/dist/bin/rollup

View file

@ -0,0 +1 @@
../sass/sass.js

Some files were not shown because too many files have changed in this diff Show more