refactor(display): compose window/systray/menu sub-packages into orchestrator

Service now delegates to window.Manager, systray.Manager, and menu.Manager
instead of directly using Wails types. WSEventManager accepts EventSource
interface instead of calling application.Get() directly.
AttachWindowListeners now accepts window.PlatformWindow.

Removes migrated files: window.go, window_state.go, layout.go, tray.go, menu.go.
Tests rewritten against mock platform implementations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-03-13 12:27:19 +00:00
parent 691f17ea05
commit 4814f960fb
16 changed files with 1194 additions and 2856 deletions

52
go.mod
View file

@ -3,92 +3,54 @@ module forge.lthn.ai/core/gui
go 1.26.0
require (
forge.lthn.ai/Snider/Enchantrix v0.0.4
forge.lthn.ai/core/go-i18n v0.0.1
github.com/adrg/xdg v0.5.3
github.com/gin-gonic/gin v1.11.0
forge.lthn.ai/core/go v0.2.2
github.com/gorilla/websocket v1.5.3
github.com/modelcontextprotocol/go-sdk v1.3.0
github.com/nicksnyder/go-i18n/v2 v2.6.1
github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1
github.com/wailsapp/wails/v3 v3.0.0-alpha.64
golang.org/x/text v0.34.0
gopkg.in/ini.v1 v1.67.1
gopkg.in/yaml.v2 v2.4.0
github.com/wailsapp/wails/v3 v3.0.0-alpha.74
)
require (
dario.cat/mergo v1.0.2 // indirect
forge.lthn.ai/core/go-inference v0.0.1 // indirect
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // 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/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/coder/websocket v1.8.14 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
github.com/gin-contrib/sse v1.1.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-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.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/json-iterator/go v1.1.12 // 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/leaanthony/u v1.1.1 // indirect
github.com/leodido/go-urn v1.4.0 // 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/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pjbgf/sha1cd v0.5.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.52.0 // indirect
github.com/sergi/go-diff v1.4.0 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // 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.uber.org/mock v0.6.0 // indirect
golang.org/x/arch v0.23.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
golang.org/x/net v0.50.0 // indirect
golang.org/x/oauth2 v0.35.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/tools v0.42.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
golang.org/x/crypto v0.47.0 // indirect
golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

101
go.sum
View file

@ -1,12 +1,9 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
forge.lthn.ai/Snider/Enchantrix v0.0.4 h1:biwpix/bdedfyc0iVeK15awhhJKH6TEMYOTXzHXx5TI=
forge.lthn.ai/core/go-i18n v0.0.1 h1:7I2cOv3GCc7MssLny/CAnwz3L7/Y4iqwzrCRQMQ+teA=
forge.lthn.ai/core/go-inference v0.0.1 h1:hf5eOzm5sNDifhb0BscMTyKEkB44r2Tv58wakHGvtz4=
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=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
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=
@ -20,32 +17,22 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd
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/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
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/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
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=
@ -60,37 +47,20 @@ github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRko
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-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
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.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8=
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/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
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=
@ -106,8 +76,6 @@ github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed
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/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
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=
@ -117,17 +85,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
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.3.0 h1:gMfZkv3DzQF5q/DcQePo5rahEY+sguyPfXDfNBcT0Zs=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/nicksnyder/go-i18n/v2 v2.6.1 h1:JDEJraFsQE17Dut9HFDHzCoAWGEQJom5s0TRd17NIEQ=
github.com/nicksnyder/go-i18n/v2 v2.6.1/go.mod h1:Vee0/9RD3Quc/NmwEjzzD7VTZ+Ir7QbXocrkhOzmUKA=
github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k=
github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0=
github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
@ -136,14 +95,12 @@ 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/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
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/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
@ -151,44 +108,25 @@ github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepq
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg=
github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
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/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
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.64 h1:xAhLFVfdbg7XdZQ5mMQmBv2BglWu8hMqe50Z+3UJvBs=
github.com/wailsapp/wails/v3 v3.0.0-alpha.64/go.mod h1:zvgNL/mlFcX8aRGu6KOz9AHrMmTBD+4hJRQIONqF/Yw=
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.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
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/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
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/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
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=
@ -198,25 +136,22 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
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.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.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.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
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.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
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=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
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/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k=
gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
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 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -1,20 +1,9 @@
// pkg/display/actions.go
package display
import "github.com/wailsapp/wails/v3/pkg/application"
import "forge.lthn.ai/core/gui/pkg/window"
// ActionOpenWindow is an IPC message used to request a new window. It contains
// the options for the new window.
//
// example:
//
// action := display.ActionOpenWindow{
// WebviewWindowOptions: application.WebviewWindowOptions{
// Name: "my-window",
// Title: "My Window",
// Width: 800,
// Height: 600,
// },
// }
// ActionOpenWindow is an IPC message type requesting a new window.
type ActionOpenWindow struct {
application.WebviewWindowOptions
window.Window
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -7,9 +7,8 @@ import (
"sync"
"time"
"forge.lthn.ai/core/gui/pkg/window"
"github.com/gorilla/websocket"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/events"
)
// EventType represents the type of event.
@ -45,7 +44,7 @@ type WSEventManager struct {
upgrader websocket.Upgrader
clients map[*websocket.Conn]*clientState
mu sync.RWMutex
display *Service
eventSource EventSource
nextSubID int
eventBuffer chan Event
}
@ -57,7 +56,8 @@ type clientState struct {
}
// NewWSEventManager creates a new event manager.
func NewWSEventManager(display *Service) *WSEventManager {
// It accepts an EventSource for theme change events instead of using application.Get() directly.
func NewWSEventManager(es EventSource) *WSEventManager {
em := &WSEventManager{
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
@ -67,7 +67,7 @@ func NewWSEventManager(display *Service) *WSEventManager {
WriteBufferSize: 1024,
},
clients: make(map[*websocket.Conn]*clientState),
display: display,
eventSource: es,
eventBuffer: make(chan Event, 100),
}
@ -302,64 +302,34 @@ func (em *WSEventManager) Close() {
close(em.eventBuffer)
}
// SetupWindowEventListeners attaches event listeners to all windows.
// SetupWindowEventListeners registers listeners for application-level events.
// Uses EventSource instead of application.Get() directly.
func (em *WSEventManager) SetupWindowEventListeners() {
app := application.Get()
if app == nil {
return
}
// Listen for theme changes
app.Event.OnApplicationEvent(events.Common.ThemeChanged, func(event *application.ApplicationEvent) {
isDark := app.Env.IsDarkMode()
em.Emit(Event{
Type: EventThemeChange,
Data: map[string]any{
"isDark": isDark,
"theme": map[bool]string{true: "dark", false: "light"}[isDark],
},
if em.eventSource != nil {
em.eventSource.OnThemeChange(func(isDark bool) {
theme := "light"
if isDark {
theme = "dark"
}
em.Emit(Event{
Type: EventThemeChange,
Data: map[string]any{
"isDark": isDark,
"theme": theme,
},
})
})
})
}
}
// AttachWindowListeners attaches event listeners to a specific window.
func (em *WSEventManager) AttachWindowListeners(window *application.WebviewWindow) {
if window == nil {
// Accepts window.PlatformWindow instead of *application.WebviewWindow.
func (em *WSEventManager) AttachWindowListeners(pw window.PlatformWindow) {
if pw == nil {
return
}
name := window.Name()
// Window focus
window.OnWindowEvent(events.Common.WindowFocus, func(event *application.WindowEvent) {
em.EmitWindowEvent(EventWindowFocus, name, nil)
})
// Window blur
window.OnWindowEvent(events.Common.WindowLostFocus, func(event *application.WindowEvent) {
em.EmitWindowEvent(EventWindowBlur, name, nil)
})
// Window move
window.OnWindowEvent(events.Common.WindowDidMove, func(event *application.WindowEvent) {
x, y := window.Position()
em.EmitWindowEvent(EventWindowMove, name, map[string]any{
"x": x,
"y": y,
})
})
// Window resize
window.OnWindowEvent(events.Common.WindowDidResize, func(event *application.WindowEvent) {
width, height := window.Size()
em.EmitWindowEvent(EventWindowResize, name, map[string]any{
"width": width,
"height": height,
})
})
// Window close
window.OnWindowEvent(events.Common.WindowClosing, func(event *application.WindowEvent) {
em.EmitWindowEvent(EventWindowClose, name, nil)
pw.OnWindowEvent(func(e window.WindowEvent) {
em.EmitWindowEvent(EventType("window."+e.Type), e.Name, e.Data)
})
}

View file

@ -1,3 +1,4 @@
// pkg/display/interfaces.go
package display
import (
@ -5,60 +6,40 @@ import (
"github.com/wailsapp/wails/v3/pkg/events"
)
// App abstracts the Wails application API for testing.
// App abstracts the Wails application for the orchestrator.
type App interface {
Window() WindowManager
Menu() MenuManager
Dialog() DialogManager
SystemTray() SystemTrayManager
Env() EnvManager
Event() EventManager
Logger() Logger
Quit()
}
// WindowManager handles window creation and management.
type WindowManager interface {
NewWithOptions(opts application.WebviewWindowOptions) *application.WebviewWindow
GetAll() []application.Window
}
// MenuManager handles menu creation.
type MenuManager interface {
New() *application.Menu
Set(menu *application.Menu)
}
// DialogManager handles dialog creation.
// DialogManager wraps Wails dialog operations.
type DialogManager interface {
Info() *application.MessageDialog
Warning() *application.MessageDialog
OpenFile() *application.OpenFileDialogStruct
}
// SystemTrayManager handles system tray creation.
type SystemTrayManager interface {
New() *application.SystemTray
}
// EnvManager provides environment information.
// EnvManager wraps Wails environment queries.
type EnvManager interface {
Info() application.EnvironmentInfo
IsDarkMode() bool
}
// EventManager handles event registration and emission.
// EventManager wraps Wails application events.
type EventManager interface {
OnApplicationEvent(eventType events.ApplicationEventType, handler func(*application.ApplicationEvent)) func()
Emit(name string, data ...any) bool
}
// Logger provides logging capabilities.
// Logger wraps Wails logging.
type Logger interface {
Info(message string, args ...any)
}
// wailsApp wraps a real Wails application to implement the App interface.
// wailsApp wraps *application.App for the App interface.
type wailsApp struct {
app *application.App
}
@ -67,53 +48,47 @@ func newWailsApp(app *application.App) App {
return &wailsApp{app: app}
}
func (w *wailsApp) Window() WindowManager { return &wailsWindowManager{app: w.app} }
func (w *wailsApp) Menu() MenuManager { return &wailsMenuManager{app: w.app} }
func (w *wailsApp) Dialog() DialogManager { return &wailsDialogManager{app: w.app} }
func (w *wailsApp) SystemTray() SystemTrayManager { return &wailsSystemTrayManager{app: w.app} }
func (w *wailsApp) Env() EnvManager { return &wailsEnvManager{app: w.app} }
func (w *wailsApp) Event() EventManager { return &wailsEventManager{app: w.app} }
func (w *wailsApp) Logger() Logger { return w.app.Logger }
func (w *wailsApp) Quit() { w.app.Quit() }
// Wails adapter implementations
type wailsWindowManager struct{ app *application.App }
func (m *wailsWindowManager) NewWithOptions(opts application.WebviewWindowOptions) *application.WebviewWindow {
return m.app.Window.NewWithOptions(opts)
}
func (m *wailsWindowManager) GetAll() []application.Window {
return m.app.Window.GetAll()
}
type wailsMenuManager struct{ app *application.App }
func (m *wailsMenuManager) New() *application.Menu { return m.app.Menu.New() }
func (m *wailsMenuManager) Set(menu *application.Menu) { m.app.Menu.Set(menu) }
func (w *wailsApp) Dialog() DialogManager { return &wailsDialogManager{app: w.app} }
func (w *wailsApp) Env() EnvManager { return &wailsEnvManager{app: w.app} }
func (w *wailsApp) Event() EventManager { return &wailsEventManager{app: w.app} }
func (w *wailsApp) Logger() Logger { return w.app.Logger }
func (w *wailsApp) Quit() { w.app.Quit() }
type wailsDialogManager struct{ app *application.App }
func (m *wailsDialogManager) Info() *application.MessageDialog { return m.app.Dialog.Info() }
func (m *wailsDialogManager) Warning() *application.MessageDialog { return m.app.Dialog.Warning() }
func (m *wailsDialogManager) OpenFile() *application.OpenFileDialogStruct {
return m.app.Dialog.OpenFile()
func (d *wailsDialogManager) Info() *application.MessageDialog { return d.app.Dialog.Info() }
func (d *wailsDialogManager) Warning() *application.MessageDialog { return d.app.Dialog.Warning() }
func (d *wailsDialogManager) OpenFile() *application.OpenFileDialogStruct {
return d.app.Dialog.OpenFile()
}
type wailsSystemTrayManager struct{ app *application.App }
func (m *wailsSystemTrayManager) New() *application.SystemTray { return m.app.SystemTray.New() }
type wailsEnvManager struct{ app *application.App }
func (m *wailsEnvManager) Info() application.EnvironmentInfo { return m.app.Env.Info() }
func (m *wailsEnvManager) IsDarkMode() bool { return m.app.Env.IsDarkMode() }
func (e *wailsEnvManager) Info() application.EnvironmentInfo { return e.app.Env.Info() }
func (e *wailsEnvManager) IsDarkMode() bool { return e.app.Env.IsDarkMode() }
type wailsEventManager struct{ app *application.App }
func (m *wailsEventManager) OnApplicationEvent(eventType events.ApplicationEventType, handler func(*application.ApplicationEvent)) func() {
return m.app.Event.OnApplicationEvent(eventType, handler)
func (ev *wailsEventManager) OnApplicationEvent(eventType events.ApplicationEventType, handler func(*application.ApplicationEvent)) func() {
return ev.app.Event.OnApplicationEvent(eventType, handler)
}
func (m *wailsEventManager) Emit(name string, data ...any) bool {
return m.app.Event.Emit(name, data...)
func (ev *wailsEventManager) Emit(name string, data ...any) bool {
return ev.app.Event.Emit(name, data...)
}
// wailsEventSource implements EventSource using a Wails app.
type wailsEventSource struct{ app *application.App }
func newWailsEventSource(app *application.App) EventSource {
return &wailsEventSource{app: app}
}
func (es *wailsEventSource) OnThemeChange(handler func(isDark bool)) func() {
return es.app.Event.OnApplicationEvent(events.Common.ThemeChanged, func(_ *application.ApplicationEvent) {
handler(es.app.Env.IsDarkMode())
})
}
func (es *wailsEventSource) Emit(name string, data ...any) bool {
return es.app.Event.Emit(name, data...)
}

View file

@ -1,149 +0,0 @@
package display
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// Layout represents a saved window arrangement.
type Layout struct {
Name string `json:"name"`
Windows map[string]WindowState `json:"windows"`
CreatedAt int64 `json:"createdAt"`
UpdatedAt int64 `json:"updatedAt"`
}
// LayoutManager handles saving and restoring window layouts.
type LayoutManager struct {
layouts map[string]*Layout
filePath string
mu sync.RWMutex
}
// NewLayoutManager creates a new layout manager.
func NewLayoutManager() *LayoutManager {
m := &LayoutManager{
layouts: make(map[string]*Layout),
}
// Determine config path
configDir, err := os.UserConfigDir()
if err != nil {
configDir = "."
}
m.filePath = filepath.Join(configDir, "Core", "layouts.json")
// Ensure directory exists
os.MkdirAll(filepath.Dir(m.filePath), 0755)
// Load existing layouts
m.load()
return m
}
// load reads layouts from disk.
func (m *LayoutManager) load() error {
m.mu.Lock()
defer m.mu.Unlock()
data, err := os.ReadFile(m.filePath)
if err != nil {
if os.IsNotExist(err) {
return nil // No saved layouts yet
}
return err
}
return json.Unmarshal(data, &m.layouts)
}
// save writes layouts to disk.
func (m *LayoutManager) save() error {
m.mu.RLock()
data, err := json.MarshalIndent(m.layouts, "", " ")
m.mu.RUnlock()
if err != nil {
return err
}
return os.WriteFile(m.filePath, data, 0644)
}
// SaveLayout saves a new layout or updates an existing one.
func (m *LayoutManager) SaveLayout(name string, windows map[string]WindowState) error {
if name == "" {
return fmt.Errorf("layout name is required")
}
m.mu.Lock()
now := time.Now().Unix()
existing, ok := m.layouts[name]
if ok {
// Update existing layout
existing.Windows = windows
existing.UpdatedAt = now
} else {
// Create new layout
m.layouts[name] = &Layout{
Name: name,
Windows: windows,
CreatedAt: now,
UpdatedAt: now,
}
}
m.mu.Unlock()
return m.save()
}
// GetLayout returns a layout by name.
func (m *LayoutManager) GetLayout(name string) *Layout {
m.mu.RLock()
defer m.mu.RUnlock()
return m.layouts[name]
}
// ListLayouts returns all saved layout names with metadata.
func (m *LayoutManager) ListLayouts() []LayoutInfo {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]LayoutInfo, 0, len(m.layouts))
for _, layout := range m.layouts {
result = append(result, LayoutInfo{
Name: layout.Name,
WindowCount: len(layout.Windows),
CreatedAt: layout.CreatedAt,
UpdatedAt: layout.UpdatedAt,
})
}
return result
}
// DeleteLayout removes a layout by name.
func (m *LayoutManager) DeleteLayout(name string) error {
m.mu.Lock()
if _, ok := m.layouts[name]; !ok {
m.mu.Unlock()
return fmt.Errorf("layout not found: %s", name)
}
delete(m.layouts, name)
m.mu.Unlock()
return m.save()
}
// LayoutInfo contains summary information about a layout.
type LayoutInfo struct {
Name string `json:"name"`
WindowCount int `json:"windowCount"`
CreatedAt int64 `json:"createdAt"`
UpdatedAt int64 `json:"updatedAt"`
}

View file

@ -1,185 +0,0 @@
package display
import (
"fmt"
"runtime"
"strings"
"github.com/wailsapp/wails/v3/pkg/application"
)
// buildMenu creates and sets the main application menu. This function is called
// during the startup of the display service.
func (s *Service) buildMenu() {
appMenu := s.app.Menu().New()
if runtime.GOOS == "darwin" {
appMenu.AddRole(application.AppMenu)
}
appMenu.AddRole(application.FileMenu)
appMenu.AddRole(application.ViewMenu)
appMenu.AddRole(application.EditMenu)
workspace := appMenu.AddSubmenu("Workspace")
workspace.Add("New...").OnClick(func(ctx *application.Context) {
s.handleNewWorkspace()
})
workspace.Add("List").OnClick(func(ctx *application.Context) {
s.handleListWorkspaces()
})
// Developer menu for IDE features
developer := appMenu.AddSubmenu("Developer")
developer.Add("New File").SetAccelerator("CmdOrCtrl+N").OnClick(func(ctx *application.Context) {
s.handleNewFile()
})
developer.Add("Open File...").SetAccelerator("CmdOrCtrl+O").OnClick(func(ctx *application.Context) {
s.handleOpenFile()
})
developer.Add("Save").SetAccelerator("CmdOrCtrl+S").OnClick(func(ctx *application.Context) {
s.handleSaveFile()
})
developer.AddSeparator()
developer.Add("Editor").OnClick(func(ctx *application.Context) {
s.handleOpenEditor()
})
developer.Add("Terminal").OnClick(func(ctx *application.Context) {
s.handleOpenTerminal()
})
developer.AddSeparator()
developer.Add("Run").SetAccelerator("CmdOrCtrl+R").OnClick(func(ctx *application.Context) {
s.handleRun()
})
developer.Add("Build").SetAccelerator("CmdOrCtrl+B").OnClick(func(ctx *application.Context) {
s.handleBuild()
})
appMenu.AddRole(application.WindowMenu)
appMenu.AddRole(application.HelpMenu)
s.app.Menu().Set(appMenu)
}
// handleNewWorkspace opens a window for creating a new workspace.
func (s *Service) handleNewWorkspace() {
// Open a dedicated window for workspace creation
// The frontend at /workspace/new handles the form
opts := application.WebviewWindowOptions{
Name: "workspace-new",
Title: "New Workspace",
Width: 500,
Height: 400,
URL: "/workspace/new",
}
s.app.Window().NewWithOptions(opts)
}
// handleListWorkspaces shows a dialog with available workspaces.
func (s *Service) handleListWorkspaces() {
// Get workspace service from core
ws := s.Core().Service("workspace")
if ws == nil {
dialog := s.app.Dialog().Warning()
dialog.SetTitle("Workspace")
dialog.SetMessage("Workspace service not available")
dialog.Show()
return
}
// Type assert to access ListWorkspaces method
lister, ok := ws.(interface{ ListWorkspaces() []string })
if !ok {
dialog := s.app.Dialog().Warning()
dialog.SetTitle("Workspace")
dialog.SetMessage("Unable to list workspaces")
dialog.Show()
return
}
workspaces := lister.ListWorkspaces()
var message string
if len(workspaces) == 0 {
message = "No workspaces found.\n\nUse Workspace → New to create one."
} else {
message = fmt.Sprintf("Available Workspaces (%d):\n\n%s",
len(workspaces),
strings.Join(workspaces, "\n"))
}
dialog := s.app.Dialog().Info()
dialog.SetTitle("Workspaces")
dialog.SetMessage(message)
dialog.Show()
}
// handleNewFile opens the editor with a new untitled file.
func (s *Service) handleNewFile() {
opts := application.WebviewWindowOptions{
Name: "editor",
Title: "New File - Editor",
Width: 1200,
Height: 800,
URL: "/#/developer/editor?new=true",
}
s.app.Window().NewWithOptions(opts)
}
// handleOpenFile opens a file dialog to select a file, then opens it in the editor.
func (s *Service) handleOpenFile() {
dialog := s.app.Dialog().OpenFile()
dialog.SetTitle("Open File")
dialog.CanChooseFiles(true)
dialog.CanChooseDirectories(false)
result, err := dialog.PromptForSingleSelection()
if err != nil || result == "" {
return
}
opts := application.WebviewWindowOptions{
Name: "editor",
Title: result + " - Editor",
Width: 1200,
Height: 800,
URL: "/#/developer/editor?file=" + result,
}
s.app.Window().NewWithOptions(opts)
}
// handleSaveFile emits a save event to the focused editor window.
func (s *Service) handleSaveFile() {
s.app.Event().Emit("ide:save")
}
// handleOpenEditor opens a standalone editor window.
func (s *Service) handleOpenEditor() {
opts := application.WebviewWindowOptions{
Name: "editor",
Title: "Editor",
Width: 1200,
Height: 800,
URL: "/#/developer/editor",
}
s.app.Window().NewWithOptions(opts)
}
// handleOpenTerminal opens a terminal window.
func (s *Service) handleOpenTerminal() {
opts := application.WebviewWindowOptions{
Name: "terminal",
Title: "Terminal",
Width: 800,
Height: 500,
URL: "/#/developer/terminal",
}
s.app.Window().NewWithOptions(opts)
}
// handleRun emits a run event that the IDE service can handle.
func (s *Service) handleRun() {
s.app.Event().Emit("ide:run")
}
// handleBuild emits a build event that the IDE service can handle.
func (s *Service) handleBuild() {
s.app.Event().Emit("ide:build")
}

View file

@ -7,10 +7,7 @@ import (
// mockApp is a mock implementation of the App interface for testing.
type mockApp struct {
windowManager *mockWindowManager
menuManager *mockMenuManager
dialogManager *mockDialogManager
systemTrayMgr *mockSystemTrayManager
envManager *mockEnvManager
eventManager *mockEventManager
logger *mockLogger
@ -19,66 +16,18 @@ type mockApp struct {
func newMockApp() *mockApp {
return &mockApp{
windowManager: newMockWindowManager(),
menuManager: newMockMenuManager(),
dialogManager: newMockDialogManager(),
systemTrayMgr: newMockSystemTrayManager(),
envManager: newMockEnvManager(),
eventManager: newMockEventManager(),
logger: &mockLogger{},
}
}
func (m *mockApp) Window() WindowManager { return m.windowManager }
func (m *mockApp) Menu() MenuManager { return m.menuManager }
func (m *mockApp) Dialog() DialogManager { return m.dialogManager }
func (m *mockApp) SystemTray() SystemTrayManager { return m.systemTrayMgr }
func (m *mockApp) Env() EnvManager { return m.envManager }
func (m *mockApp) Event() EventManager { return m.eventManager }
func (m *mockApp) Logger() Logger { return m.logger }
func (m *mockApp) Quit() { m.quitCalled = true }
// mockWindowManager tracks window creation calls.
type mockWindowManager struct {
createdWindows []application.WebviewWindowOptions
allWindows []application.Window
}
func newMockWindowManager() *mockWindowManager {
return &mockWindowManager{
createdWindows: make([]application.WebviewWindowOptions, 0),
allWindows: make([]application.Window, 0),
}
}
func (m *mockWindowManager) NewWithOptions(opts application.WebviewWindowOptions) *application.WebviewWindow {
m.createdWindows = append(m.createdWindows, opts)
// Return nil since we can't create a real window without Wails runtime
return nil
}
func (m *mockWindowManager) GetAll() []application.Window {
return m.allWindows
}
// mockMenuManager tracks menu creation calls.
type mockMenuManager struct {
menusCreated int
menuSet *application.Menu
}
func newMockMenuManager() *mockMenuManager {
return &mockMenuManager{}
}
func (m *mockMenuManager) New() *application.Menu {
m.menusCreated++
return nil // Can't create real menu without Wails runtime
}
func (m *mockMenuManager) Set(menu *application.Menu) {
m.menuSet = menu
}
func (m *mockApp) Dialog() DialogManager { return m.dialogManager }
func (m *mockApp) Env() EnvManager { return m.envManager }
func (m *mockApp) Event() EventManager { return m.eventManager }
func (m *mockApp) Logger() Logger { return m.logger }
func (m *mockApp) Quit() { m.quitCalled = true }
// mockDialogManager tracks dialog creation calls.
type mockDialogManager struct {
@ -104,20 +53,6 @@ func (m *mockDialogManager) OpenFile() *application.OpenFileDialogStruct {
return nil // Can't create real dialog without Wails runtime
}
// mockSystemTrayManager tracks system tray creation calls.
type mockSystemTrayManager struct {
traysCreated int
}
func newMockSystemTrayManager() *mockSystemTrayManager {
return &mockSystemTrayManager{}
}
func (m *mockSystemTrayManager) New() *application.SystemTray {
m.traysCreated++
return nil // Can't create real system tray without Wails runtime
}
// mockEnvManager provides mock environment info.
type mockEnvManager struct {
envInfo application.EnvironmentInfo
@ -147,11 +82,13 @@ func (m *mockEnvManager) IsDarkMode() bool {
// mockEventManager tracks event registration.
type mockEventManager struct {
registeredEvents []events.ApplicationEventType
emittedEvents []string
}
func newMockEventManager() *mockEventManager {
return &mockEventManager{
registeredEvents: make([]events.ApplicationEventType, 0),
emittedEvents: make([]string, 0),
}
}
@ -161,6 +98,7 @@ func (m *mockEventManager) OnApplicationEvent(eventType events.ApplicationEventT
}
func (m *mockEventManager) Emit(name string, data ...any) bool {
m.emittedEvents = append(m.emittedEvents, name)
return true // Pretend emission succeeded
}
@ -172,3 +110,21 @@ type mockLogger struct {
func (m *mockLogger) Info(message string, args ...any) {
m.infoMessages = append(m.infoMessages, message)
}
// mockEventSource implements EventSource for testing.
type mockEventSource struct {
themeHandlers []func(isDark bool)
}
func newMockEventSource() *mockEventSource {
return &mockEventSource{}
}
func (m *mockEventSource) OnThemeChange(handler func(isDark bool)) func() {
m.themeHandlers = append(m.themeHandlers, handler)
return func() {}
}
func (m *mockEventSource) Emit(name string, data ...any) bool {
return true
}

View file

@ -1,200 +0,0 @@
package display
import (
"embed"
"fmt"
"runtime"
"github.com/wailsapp/wails/v3/pkg/application"
)
//go:embed assets/apptray.png
var assets embed.FS
// activeTray holds the reference to the system tray for management.
var activeTray *application.SystemTray
// systemTray configures and creates the system tray icon and menu.
func (s *Service) systemTray() {
systray := s.app.SystemTray().New()
activeTray = systray
systray.SetTooltip("Core")
systray.SetLabel("Core")
// Load and set tray icon
appTrayIcon, err := assets.ReadFile("assets/apptray.png")
if err == nil {
if runtime.GOOS == "darwin" {
systray.SetTemplateIcon(appTrayIcon)
} else {
// Support for light/dark mode icons
systray.SetDarkModeIcon(appTrayIcon)
systray.SetIcon(appTrayIcon)
}
}
// Create a hidden window for the system tray menu to interact with
trayWindow, _ := s.NewWithStruct(&Window{
Name: "system-tray",
Title: "System Tray Status",
URL: "/system-tray",
Width: 400,
Frameless: true,
Hidden: true,
})
systray.AttachWindow(trayWindow).WindowOffset(5)
// --- Build Tray Menu ---
trayMenu := s.app.Menu().New()
trayMenu.Add("Open Desktop").OnClick(func(ctx *application.Context) {
for _, window := range s.app.Window().GetAll() {
window.Show()
}
})
trayMenu.Add("Close Desktop").OnClick(func(ctx *application.Context) {
for _, window := range s.app.Window().GetAll() {
window.Hide()
}
})
trayMenu.Add("Environment Info").OnClick(func(ctx *application.Context) {
s.ShowEnvironmentDialog()
})
// Add brand-specific menu items
//switch d.brand {
//case AdminHub:
// trayMenu.Add("Manage Workspace").OnClick(func(ctx *application.Context) { /* TODO */ })
//case ServerHub:
// trayMenu.Add("Server Control").OnClick(func(ctx *application.Context) { /* TODO */ })
//case GatewayHub:
// trayMenu.Add("Routing Table").OnClick(func(ctx *application.Context) { /* TODO */ })
//case DeveloperHub:
// trayMenu.Add("Debug Console").OnClick(func(ctx *application.Context) { /* TODO */ })
//case ClientHub:
// trayMenu.Add("Connect").OnClick(func(ctx *application.Context) { /* TODO */ })
// trayMenu.Add("Disconnect").OnClick(func(ctx *application.Context) { /* TODO */ })
//}
trayMenu.AddSeparator()
trayMenu.Add("Quit").OnClick(func(ctx *application.Context) {
s.app.Quit()
})
systray.SetMenu(trayMenu)
}
// SetTrayIcon sets the system tray icon from raw PNG data.
func (s *Service) SetTrayIcon(iconData []byte) error {
if activeTray == nil {
return fmt.Errorf("system tray not initialized")
}
if runtime.GOOS == "darwin" {
activeTray.SetTemplateIcon(iconData)
} else {
activeTray.SetIcon(iconData)
}
return nil
}
// SetTrayTooltip sets the system tray tooltip text.
func (s *Service) SetTrayTooltip(tooltip string) error {
if activeTray == nil {
return fmt.Errorf("system tray not initialized")
}
activeTray.SetTooltip(tooltip)
return nil
}
// SetTrayLabel sets the system tray label text.
func (s *Service) SetTrayLabel(label string) error {
if activeTray == nil {
return fmt.Errorf("system tray not initialized")
}
activeTray.SetLabel(label)
return nil
}
// TrayMenuItem represents a menu item for the system tray.
type TrayMenuItem struct {
Label string `json:"label"`
Type string `json:"type,omitempty"` // "normal", "separator", "checkbox", "radio"
Checked bool `json:"checked,omitempty"` // for checkbox/radio items
Disabled bool `json:"disabled,omitempty"`
Tooltip string `json:"tooltip,omitempty"`
Submenu []TrayMenuItem `json:"submenu,omitempty"`
ActionID string `json:"actionId,omitempty"` // ID for callback
}
// trayMenuCallbacks stores callbacks for tray menu items.
var trayMenuCallbacks = make(map[string]func())
// SetTrayMenu sets the system tray menu from a list of menu items.
func (s *Service) SetTrayMenu(items []TrayMenuItem) error {
if activeTray == nil {
return fmt.Errorf("system tray not initialized")
}
menu := s.app.Menu().New()
s.buildTrayMenu(menu, items)
activeTray.SetMenu(menu)
return nil
}
// buildTrayMenu recursively builds a menu from TrayMenuItem items.
func (s *Service) buildTrayMenu(menu *application.Menu, items []TrayMenuItem) {
for _, item := range items {
switch item.Type {
case "separator":
menu.AddSeparator()
case "checkbox":
menuItem := menu.AddCheckbox(item.Label, item.Checked)
if item.Disabled {
menuItem.SetEnabled(false)
}
if item.ActionID != "" {
actionID := item.ActionID
menuItem.OnClick(func(ctx *application.Context) {
if cb, ok := trayMenuCallbacks[actionID]; ok {
cb()
}
})
}
default:
if len(item.Submenu) > 0 {
submenu := menu.AddSubmenu(item.Label)
s.buildTrayMenu(submenu, item.Submenu)
} else {
menuItem := menu.Add(item.Label)
if item.Disabled {
menuItem.SetEnabled(false)
}
if item.Tooltip != "" {
menuItem.SetTooltip(item.Tooltip)
}
if item.ActionID != "" {
actionID := item.ActionID
menuItem.OnClick(func(ctx *application.Context) {
if cb, ok := trayMenuCallbacks[actionID]; ok {
cb()
}
})
}
}
}
}
}
// RegisterTrayMenuCallback registers a callback for a tray menu action ID.
func (s *Service) RegisterTrayMenuCallback(actionID string, callback func()) {
trayMenuCallbacks[actionID] = callback
}
// GetTrayInfo returns information about the current tray state.
func (s *Service) GetTrayInfo() map[string]any {
if activeTray == nil {
return map[string]any{"active": false}
}
return map[string]any{
"active": true,
}
}

View file

@ -1,90 +0,0 @@
package display
import (
"github.com/wailsapp/wails/v3/pkg/application"
)
type WindowOption func(*application.WebviewWindowOptions) error
type Window = application.WebviewWindowOptions
func WindowName(s string) WindowOption {
return func(o *Window) error {
o.Name = s
return nil
}
}
func WindowTitle(s string) WindowOption {
return func(o *Window) error {
o.Title = s
return nil
}
}
func WindowURL(s string) WindowOption {
return func(o *Window) error {
o.URL = s
return nil
}
}
func WindowWidth(i int) WindowOption {
return func(o *Window) error {
o.Width = i
return nil
}
}
func WindowHeight(i int) WindowOption {
return func(o *Window) error {
o.Height = i
return nil
}
}
func applyOptions(opts ...WindowOption) *Window {
w := &Window{}
if opts == nil {
return w
}
for _, o := range opts {
if err := o(w); err != nil {
return nil
}
}
return w
}
// NewWithStruct creates a new window using the provided options and returns its handle.
func (s *Service) NewWithStruct(options *Window) (*application.WebviewWindow, error) {
return s.app.Window().NewWithOptions(*options), nil
}
// NewWithOptions creates a new window by applying a series of options.
func (s *Service) NewWithOptions(opts ...WindowOption) (*application.WebviewWindow, error) {
return s.NewWithStruct(applyOptions(opts...))
}
// NewWithURL creates a new default window pointing to the specified URL.
func (s *Service) NewWithURL(url string) (*application.WebviewWindow, error) {
return s.NewWithOptions(
WindowURL(url),
WindowTitle("Core"),
WindowHeight(900),
WindowWidth(1280),
)
}
//// OpenWindow is a convenience method that creates and shows a window from a set of options.
//func (s *Service) OpenWindow(opts ...WindowOption) error {
// _, err := s.NewWithOptions(opts...)
// return err
//}
// SelectDirectory opens a directory selection dialog and returns the selected path.
// TODO: Update for Wails v3 API - use DialogManager.OpenFile() instead
//func (s *Service) SelectDirectory() (string, error) {
// dialog := application.OpenFileDialog()
// dialog.SetTitle("Select Project Directory")
// return dialog.PromptForSingleSelection()
//}

View file

@ -1,261 +0,0 @@
package display
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
)
// WindowState holds the persisted state of a window.
type WindowState struct {
X int `json:"x"`
Y int `json:"y"`
Width int `json:"width"`
Height int `json:"height"`
Maximized bool `json:"maximized"`
Screen string `json:"screen,omitempty"` // Screen identifier for multi-monitor
URL string `json:"url,omitempty"` // Last URL/route
UpdatedAt int64 `json:"updatedAt"`
}
// WindowStateManager handles saving and restoring window positions.
type WindowStateManager struct {
states map[string]*WindowState
filePath string
mu sync.RWMutex
dirty bool
saveTimer *time.Timer
}
// NewWindowStateManager creates a new window state manager.
// It loads existing state from the config directory.
func NewWindowStateManager() *WindowStateManager {
m := &WindowStateManager{
states: make(map[string]*WindowState),
}
// Determine config path
configDir, err := os.UserConfigDir()
if err != nil {
configDir = "."
}
m.filePath = filepath.Join(configDir, "Core", "window_state.json")
// Ensure directory exists
os.MkdirAll(filepath.Dir(m.filePath), 0755)
// Load existing state
m.load()
return m
}
// load reads window states from disk.
func (m *WindowStateManager) load() error {
m.mu.Lock()
defer m.mu.Unlock()
data, err := os.ReadFile(m.filePath)
if err != nil {
if os.IsNotExist(err) {
return nil // No saved state yet
}
return err
}
return json.Unmarshal(data, &m.states)
}
// save writes window states to disk.
func (m *WindowStateManager) save() error {
m.mu.RLock()
data, err := json.MarshalIndent(m.states, "", " ")
m.mu.RUnlock()
if err != nil {
return err
}
return os.WriteFile(m.filePath, data, 0644)
}
// scheduleSave debounces saves to avoid excessive disk writes.
func (m *WindowStateManager) scheduleSave() {
m.mu.Lock()
defer m.mu.Unlock()
m.dirty = true
// Cancel existing timer
if m.saveTimer != nil {
m.saveTimer.Stop()
}
// Schedule save after 500ms of no changes
m.saveTimer = time.AfterFunc(500*time.Millisecond, func() {
m.mu.Lock()
if m.dirty {
m.dirty = false
m.mu.Unlock()
m.save()
} else {
m.mu.Unlock()
}
})
}
// GetState returns the saved state for a window, or nil if none.
func (m *WindowStateManager) GetState(name string) *WindowState {
m.mu.RLock()
defer m.mu.RUnlock()
return m.states[name]
}
// SetState saves the state for a window.
func (m *WindowStateManager) SetState(name string, state *WindowState) {
m.mu.Lock()
state.UpdatedAt = time.Now().Unix()
m.states[name] = state
m.mu.Unlock()
m.scheduleSave()
}
// UpdatePosition updates just the position of a window.
func (m *WindowStateManager) UpdatePosition(name string, x, y int) {
m.mu.Lock()
state, ok := m.states[name]
if !ok {
state = &WindowState{}
m.states[name] = state
}
state.X = x
state.Y = y
state.UpdatedAt = time.Now().Unix()
m.mu.Unlock()
m.scheduleSave()
}
// UpdateSize updates just the size of a window.
func (m *WindowStateManager) UpdateSize(name string, width, height int) {
m.mu.Lock()
state, ok := m.states[name]
if !ok {
state = &WindowState{}
m.states[name] = state
}
state.Width = width
state.Height = height
state.UpdatedAt = time.Now().Unix()
m.mu.Unlock()
m.scheduleSave()
}
// UpdateMaximized updates the maximized state of a window.
func (m *WindowStateManager) UpdateMaximized(name string, maximized bool) {
m.mu.Lock()
state, ok := m.states[name]
if !ok {
state = &WindowState{}
m.states[name] = state
}
state.Maximized = maximized
state.UpdatedAt = time.Now().Unix()
m.mu.Unlock()
m.scheduleSave()
}
// CaptureState captures the current state from a window.
func (m *WindowStateManager) CaptureState(name string, window *application.WebviewWindow) {
if window == nil {
return
}
x, y := window.Position()
width, height := window.Size()
m.mu.Lock()
state, ok := m.states[name]
if !ok {
state = &WindowState{}
m.states[name] = state
}
state.X = x
state.Y = y
state.Width = width
state.Height = height
state.Maximized = window.IsMaximised()
state.UpdatedAt = time.Now().Unix()
m.mu.Unlock()
m.scheduleSave()
}
// ApplyState applies saved state to window options.
// Returns the modified options with position/size restored.
func (m *WindowStateManager) ApplyState(opts application.WebviewWindowOptions) application.WebviewWindowOptions {
state := m.GetState(opts.Name)
if state == nil {
return opts
}
// Only apply if we have valid saved dimensions
if state.Width > 0 && state.Height > 0 {
opts.Width = state.Width
opts.Height = state.Height
}
// Apply position (check for reasonable values)
if state.X != 0 || state.Y != 0 {
opts.X = state.X
opts.Y = state.Y
}
// Apply maximized state
if state.Maximized {
opts.StartState = application.WindowStateMaximised
}
return opts
}
// ForceSync immediately saves all state to disk.
func (m *WindowStateManager) ForceSync() error {
m.mu.Lock()
if m.saveTimer != nil {
m.saveTimer.Stop()
m.saveTimer = nil
}
m.dirty = false
m.mu.Unlock()
return m.save()
}
// Clear removes all saved window states.
func (m *WindowStateManager) Clear() error {
m.mu.Lock()
m.states = make(map[string]*WindowState)
m.mu.Unlock()
return m.save()
}
// ListStates returns all saved window names.
func (m *WindowStateManager) ListStates() []string {
m.mu.RLock()
defer m.mu.RUnlock()
names := make([]string, 0, len(m.states))
for name := range m.states {
names = append(names, name)
}
return names
}

View file

@ -46,6 +46,17 @@ func NewLayoutManager() *LayoutManager {
return lm
}
// NewLayoutManagerWithDir creates a LayoutManager loading from a custom config directory.
// Useful for testing or when the default config directory is not appropriate.
func NewLayoutManagerWithDir(configDir string) *LayoutManager {
lm := &LayoutManager{
configDir: configDir,
layouts: make(map[string]Layout),
}
lm.load()
return lm
}
func (lm *LayoutManager) filePath() string {
return filepath.Join(lm.configDir, "layouts.json")
}

View file

@ -43,6 +43,17 @@ func NewStateManager() *StateManager {
return sm
}
// NewStateManagerWithDir creates a StateManager loading from a custom config directory.
// Useful for testing or when the default config directory is not appropriate.
func NewStateManagerWithDir(configDir string) *StateManager {
sm := &StateManager{
configDir: configDir,
states: make(map[string]WindowState),
}
sm.load()
return sm
}
func (sm *StateManager) filePath() string {
return filepath.Join(sm.configDir, "window_state.json")
}

View file

@ -55,6 +55,17 @@ 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.
func NewManagerWithDir(platform Platform, configDir string) *Manager {
return &Manager{
platform: platform,
state: NewStateManagerWithDir(configDir),
layout: NewLayoutManagerWithDir(configDir),
windows: make(map[string]PlatformWindow),
}
}
// Open creates a window using functional options, applies saved state, and tracks it.
func (m *Manager) Open(opts ...WindowOption) (PlatformWindow, error) {
w, err := ApplyOptions(opts...)