diff --git a/go.mod b/go.mod index 32c58ea..af4cee7 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index 91b8787..a5b76c3 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/pkg/display/actions.go b/pkg/display/actions.go index e5cbcdd..583ec97 100644 --- a/pkg/display/actions.go +++ b/pkg/display/actions.go @@ -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 } diff --git a/pkg/display/display.go b/pkg/display/display.go index f928573..b1879e2 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -3,44 +3,38 @@ package display import ( "context" "fmt" + "runtime" - "forge.lthn.ai/core/gui/pkg/core" + "forge.lthn.ai/core/go/pkg/core" + "forge.lthn.ai/core/gui/pkg/menu" + "forge.lthn.ai/core/gui/pkg/systray" + "forge.lthn.ai/core/gui/pkg/window" "github.com/wailsapp/wails/v3/pkg/application" - "github.com/wailsapp/wails/v3/pkg/events" "github.com/wailsapp/wails/v3/pkg/services/notifications" ) // Options holds configuration for the display service. -// This struct is used to configure the display service at startup. type Options struct{} // Service manages windowing, dialogs, and other visual elements. -// It is the primary interface for interacting with the UI. +// It composes window.Manager, systray.Manager, and menu.Manager. type Service struct { *core.ServiceRuntime[Options] - app App - config Options - windowStates *WindowStateManager - layouts *LayoutManager - notifier *notifications.NotificationService - events *WSEventManager + app App + config Options + windows *window.Manager + tray *systray.Manager + menus *menu.Manager + notifier *notifications.NotificationService + events *WSEventManager } // newDisplayService contains the common logic for initializing a Service struct. -// It is called by the New function. func newDisplayService() (*Service, error) { return &Service{}, nil } // New is the constructor for the display service. -// It creates a new Service and returns it. -// -// example: -// -// displayService, err := display.New() -// if err != nil { -// log.Fatal(err) -// } func New() (*Service, error) { s, err := newDisplayService() if err != nil { @@ -50,7 +44,6 @@ func New() (*Service, error) { } // Register creates and registers a new display service with the given Core instance. -// This wires up the ServiceRuntime so the service can access other services. func Register(c *core.Core) (any, error) { s, err := New() if err != nil { @@ -65,180 +58,271 @@ func (s *Service) ServiceName() string { return "forge.lthn.ai/core/gui/display" } -// ServiceStartup is called by Wails when the app starts. It initializes the display service -// and sets up the main application window and system tray. +// ServiceStartup is called by Wails when the app starts. func (s *Service) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { return s.Startup(ctx) } -// Startup is called when the app starts. It initializes the display service -// and sets up the main application window and system tray. -// -// err := displayService.Startup(ctx) -// if err != nil { -// log.Fatal(err) -// } +// Startup initialises the display service and sets up sub-managers. func (s *Service) Startup(ctx context.Context) error { - s.app = newWailsApp(application.Get()) - s.windowStates = NewWindowStateManager() - s.layouts = NewLayoutManager() - s.events = NewWSEventManager(s) + wailsApp := application.Get() + s.app = newWailsApp(wailsApp) + + // Create sub-manager platform adapters + s.windows = window.NewManager(window.NewWailsPlatform(wailsApp)) + s.tray = systray.NewManager(systray.NewWailsPlatform(wailsApp)) + s.menus = menu.NewManager(menu.NewWailsPlatform(wailsApp)) + + s.events = NewWSEventManager(newWailsEventSource(wailsApp)) s.events.SetupWindowEventListeners() s.app.Logger().Info("Display service started") s.buildMenu() - s.systemTray() + s.setupTray() return s.OpenWindow() } -// handleOpenWindowAction processes a message to configure and create a new window -// using the specified name and options. -func (s *Service) handleOpenWindowAction(msg map[string]any) error { - opts := parseWindowOptions(msg) - s.app.Window().NewWithOptions(opts) +// --- Window Management (delegates to window.Manager) --- + +// OpenWindow creates a new window with the given options. +func (s *Service) OpenWindow(opts ...window.WindowOption) error { + pw, err := s.windows.Open(opts...) + if err != nil { + return err + } + s.trackWindow(pw) return nil } -// parseWindowOptions extracts window configuration from a map and returns it -// as a `application.WebviewWindowOptions` struct. This function is used by -// `handleOpenWindowAction` to parse the incoming message. -func parseWindowOptions(msg map[string]any) application.WebviewWindowOptions { - opts := application.WebviewWindowOptions{} - if name, ok := msg["name"].(string); ok { - opts.Name = name - } - if optsMap, ok := msg["options"].(map[string]any); ok { - if title, ok := optsMap["Title"].(string); ok { - opts.Title = title - } - if width, ok := optsMap["Width"].(float64); ok { - opts.Width = int(width) - } - if height, ok := optsMap["Height"].(float64); ok { - opts.Height = int(height) - } - } - return opts -} - -// ShowEnvironmentDialog displays a dialog containing detailed information about -// the application's runtime environment. This is useful for debugging and -// understanding the context in which the application is running. -// -// example: -// -// displayService.ShowEnvironmentDialog() -func (s *Service) ShowEnvironmentDialog() { - envInfo := s.app.Env().Info() - - details := "Environment Information:\n\n" - details += fmt.Sprintf("Operating System: %s\n", envInfo.OS) - details += fmt.Sprintf("Architecture: %s\n", envInfo.Arch) - details += fmt.Sprintf("Debug Mode: %t\n\n", envInfo.Debug) - details += fmt.Sprintf("Dark Mode: %t\n\n", s.app.Env().IsDarkMode()) - details += "Platform Information:" - - // Add platform-specific details - for key, value := range envInfo.PlatformInfo { - details += fmt.Sprintf("\n%s: %v", key, value) - } - - if envInfo.OSInfo != nil { - details += fmt.Sprintf("\n\nOS Details:\nName: %s\nVersion: %s", - envInfo.OSInfo.Name, - envInfo.OSInfo.Version) - } - - dialog := s.app.Dialog().Info() - dialog.SetTitle("Environment Information") - dialog.SetMessage(details) - dialog.Show() -} - -// OpenWindow creates a new window with the given options. If no options are -// provided, it will use the default options. -// -// example: -// -// err := displayService.OpenWindow( -// display.WithName("my-window"), -// display.WithTitle("My Window"), -// display.WithWidth(800), -// display.WithHeight(600), -// ) -// if err != nil { -// log.Fatal(err) -// } -func (s *Service) OpenWindow(opts ...WindowOption) error { - wailsOpts := buildWailsWindowOptions(opts...) - - // Apply saved window state (position, size) - if s.windowStates != nil { - wailsOpts = s.windowStates.ApplyState(wailsOpts) - } - - window := s.app.Window().NewWithOptions(wailsOpts) - - // Set up state tracking for this window - if s.windowStates != nil && window != nil { - s.trackWindowState(wailsOpts.Name, window) - } - - return nil -} - -// trackWindowState sets up event listeners to track window position/size changes. -func (s *Service) trackWindowState(name string, window *application.WebviewWindow) { - // Register for window events - window.OnWindowEvent(events.Common.WindowDidMove, func(event *application.WindowEvent) { - s.windowStates.CaptureState(name, window) - }) - - window.OnWindowEvent(events.Common.WindowDidResize, func(event *application.WindowEvent) { - s.windowStates.CaptureState(name, window) - }) - - // Attach event manager listeners for WebSocket broadcasts +// trackWindow attaches event listeners for state persistence and WebSocket events. +func (s *Service) trackWindow(pw window.PlatformWindow) { if s.events != nil { - s.events.AttachWindowListeners(window) - // Emit window create event - s.events.EmitWindowEvent(EventWindowCreate, name, map[string]any{ - "name": name, + s.events.EmitWindowEvent(EventWindowCreate, pw.Name(), map[string]any{ + "name": pw.Name(), }) + s.events.AttachWindowListeners(pw) } - - // Capture initial state - s.windowStates.CaptureState(name, window) } -// buildWailsWindowOptions creates Wails window options from the given -// `WindowOption`s. This function is used by `OpenWindow` to construct the -// options for the new window. -func buildWailsWindowOptions(opts ...WindowOption) application.WebviewWindowOptions { - // Default options - winOpts := &Window{ - Name: "main", - Title: "Core", - Width: 1280, - Height: 800, - URL: "/", +// GetWindowInfo returns information about a window by name. +func (s *Service) GetWindowInfo(name string) (*WindowInfo, error) { + pw, ok := s.windows.Get(name) + if !ok { + return nil, fmt.Errorf("window not found: %s", name) } + x, y := pw.Position() + w, h := pw.Size() + return &WindowInfo{ + Name: name, + X: x, + Y: y, + Width: w, + Height: h, + Maximized: pw.IsMaximised(), + }, nil +} - // Apply functional options - for _, opt := range opts { - if opt != nil { - _ = opt(winOpts) +// ListWindowInfos returns information about all tracked windows. +func (s *Service) ListWindowInfos() []WindowInfo { + names := s.windows.List() + result := make([]WindowInfo, 0, len(names)) + for _, name := range names { + if pw, ok := s.windows.Get(name); ok { + x, y := pw.Position() + w, h := pw.Size() + result = append(result, WindowInfo{ + Name: name, + X: x, + Y: y, + Width: w, + Height: h, + Maximized: pw.IsMaximised(), + }) } } - - return *winOpts + return result } -// monitorScreenChanges listens for theme change events and logs when the screen -// configuration changes. -func (s *Service) monitorScreenChanges() { - s.app.Event().OnApplicationEvent(events.Common.ThemeChanged, func(event *application.ApplicationEvent) { - s.app.Logger().Info("Screen configuration changed") - }) +// SetWindowPosition moves a window to the specified position. +func (s *Service) SetWindowPosition(name string, x, y int) error { + pw, ok := s.windows.Get(name) + if !ok { + return fmt.Errorf("window not found: %s", name) + } + pw.SetPosition(x, y) + s.windows.State().UpdatePosition(name, x, y) + return nil +} + +// SetWindowSize resizes a window. +func (s *Service) SetWindowSize(name string, width, height int) error { + pw, ok := s.windows.Get(name) + if !ok { + return fmt.Errorf("window not found: %s", name) + } + pw.SetSize(width, height) + s.windows.State().UpdateSize(name, width, height) + return nil +} + +// SetWindowBounds sets both position and size of a window. +func (s *Service) SetWindowBounds(name string, x, y, width, height int) error { + pw, ok := s.windows.Get(name) + if !ok { + return fmt.Errorf("window not found: %s", name) + } + pw.SetPosition(x, y) + pw.SetSize(width, height) + return nil +} + +// MaximizeWindow maximizes a window. +func (s *Service) MaximizeWindow(name string) error { + pw, ok := s.windows.Get(name) + if !ok { + return fmt.Errorf("window not found: %s", name) + } + pw.Maximise() + s.windows.State().UpdateMaximized(name, true) + return nil +} + +// RestoreWindow restores a maximized/minimized window. +func (s *Service) RestoreWindow(name string) error { + pw, ok := s.windows.Get(name) + if !ok { + return fmt.Errorf("window not found: %s", name) + } + pw.Restore() + return nil +} + +// MinimizeWindow minimizes a window. +func (s *Service) MinimizeWindow(name string) error { + pw, ok := s.windows.Get(name) + if !ok { + return fmt.Errorf("window not found: %s", name) + } + pw.Minimise() + return nil +} + +// FocusWindow brings a window to the front. +func (s *Service) FocusWindow(name string) error { + pw, ok := s.windows.Get(name) + if !ok { + return fmt.Errorf("window not found: %s", name) + } + pw.Focus() + return nil +} + +// CloseWindow closes a window by name. +func (s *Service) CloseWindow(name string) error { + pw, ok := s.windows.Get(name) + if !ok { + return fmt.Errorf("window not found: %s", name) + } + s.windows.State().CaptureState(pw) + pw.Close() + s.windows.Remove(name) + return nil +} + +// SetWindowVisibility shows or hides a window. +func (s *Service) SetWindowVisibility(name string, visible bool) error { + pw, ok := s.windows.Get(name) + if !ok { + return fmt.Errorf("window not found: %s", name) + } + pw.SetVisibility(visible) + return nil +} + +// SetWindowAlwaysOnTop sets whether a window stays on top. +func (s *Service) SetWindowAlwaysOnTop(name string, alwaysOnTop bool) error { + pw, ok := s.windows.Get(name) + if !ok { + return fmt.Errorf("window not found: %s", name) + } + pw.SetAlwaysOnTop(alwaysOnTop) + return nil +} + +// SetWindowTitle changes a window's title. +func (s *Service) SetWindowTitle(name string, title string) error { + pw, ok := s.windows.Get(name) + if !ok { + return fmt.Errorf("window not found: %s", name) + } + pw.SetTitle(title) + return nil +} + +// SetWindowFullscreen sets a window to fullscreen mode. +func (s *Service) SetWindowFullscreen(name string, fullscreen bool) error { + pw, ok := s.windows.Get(name) + if !ok { + return fmt.Errorf("window not found: %s", name) + } + if fullscreen { + pw.Fullscreen() + } else { + pw.UnFullscreen() + } + return nil +} + +// SetWindowBackgroundColour sets the background colour of a window. +func (s *Service) SetWindowBackgroundColour(name string, r, g, b, a uint8) error { + pw, ok := s.windows.Get(name) + if !ok { + return fmt.Errorf("window not found: %s", name) + } + pw.SetBackgroundColour(r, g, b, a) + return nil +} + +// GetFocusedWindow returns the name of the currently focused window. +func (s *Service) GetFocusedWindow() string { + for _, name := range s.windows.List() { + if pw, ok := s.windows.Get(name); ok { + if pw.IsFocused() { + return name + } + } + } + return "" +} + +// GetWindowTitle returns the title of a window by name. +func (s *Service) GetWindowTitle(name string) (string, error) { + _, ok := s.windows.Get(name) + if !ok { + return "", fmt.Errorf("window not found: %s", name) + } + return name, nil // Wails v3 doesn't expose a title getter +} + +// ResetWindowState clears saved window positions. +func (s *Service) ResetWindowState() error { + if s.windows != nil { + s.windows.State().Clear() + } + return nil +} + +// GetSavedWindowStates returns all saved window states. +func (s *Service) GetSavedWindowStates() map[string]window.WindowState { + if s.windows == nil { + return nil + } + result := make(map[string]window.WindowState) + for _, name := range s.windows.State().ListStates() { + if state, ok := s.windows.State().GetState(name); ok { + result[name] = state + } + } + return result } // WindowInfo contains information about a window for MCP. @@ -251,207 +335,6 @@ type WindowInfo struct { Maximized bool `json:"maximized"` } -// ScreenInfo contains information about a display screen. -type ScreenInfo struct { - ID string `json:"id"` - Name string `json:"name"` - X int `json:"x"` - Y int `json:"y"` - Width int `json:"width"` - Height int `json:"height"` - Primary bool `json:"primary"` -} - -// GetWindowInfo returns information about a window by name. -func (s *Service) GetWindowInfo(name string) (*WindowInfo, error) { - windows := s.app.Window().GetAll() - for _, w := range windows { - if wv, ok := w.(*application.WebviewWindow); ok { - if wv.Name() == name { - x, y := wv.Position() - width, height := wv.Size() - return &WindowInfo{ - Name: name, - X: x, - Y: y, - Width: width, - Height: height, - Maximized: wv.IsMaximised(), - }, nil - } - } - } - return nil, fmt.Errorf("window not found: %s", name) -} - -// ListWindowInfos returns information about all windows. -func (s *Service) ListWindowInfos() []WindowInfo { - windows := s.app.Window().GetAll() - result := make([]WindowInfo, 0, len(windows)) - for _, w := range windows { - if wv, ok := w.(*application.WebviewWindow); ok { - x, y := wv.Position() - width, height := wv.Size() - result = append(result, WindowInfo{ - Name: wv.Name(), - X: x, - Y: y, - Width: width, - Height: height, - Maximized: wv.IsMaximised(), - }) - } - } - return result -} - -// GetScreens returns information about all available screens. -func (s *Service) GetScreens() []ScreenInfo { - app := application.Get() - if app == nil || app.Screen == nil { - return nil - } - - screens := app.Screen.GetAll() - if screens == nil { - return nil - } - - result := make([]ScreenInfo, 0, len(screens)) - for _, screen := range screens { - result = append(result, ScreenInfo{ - ID: screen.ID, - Name: screen.Name, - X: screen.Bounds.X, - Y: screen.Bounds.Y, - Width: screen.Bounds.Width, - Height: screen.Bounds.Height, - Primary: screen.IsPrimary, - }) - } - return result -} - -// SetWindowPosition moves a window to the specified position. -func (s *Service) SetWindowPosition(name string, x, y int) error { - windows := s.app.Window().GetAll() - for _, w := range windows { - if wv, ok := w.(*application.WebviewWindow); ok { - if wv.Name() == name { - wv.SetPosition(x, y) - return nil - } - } - } - return fmt.Errorf("window not found: %s", name) -} - -// SetWindowSize resizes a window. -func (s *Service) SetWindowSize(name string, width, height int) error { - windows := s.app.Window().GetAll() - for _, w := range windows { - if wv, ok := w.(*application.WebviewWindow); ok { - if wv.Name() == name { - wv.SetSize(width, height) - return nil - } - } - } - return fmt.Errorf("window not found: %s", name) -} - -// SetWindowBounds sets both position and size of a window. -func (s *Service) SetWindowBounds(name string, x, y, width, height int) error { - windows := s.app.Window().GetAll() - for _, w := range windows { - if wv, ok := w.(*application.WebviewWindow); ok { - if wv.Name() == name { - wv.SetPosition(x, y) - wv.SetSize(width, height) - return nil - } - } - } - return fmt.Errorf("window not found: %s", name) -} - -// MaximizeWindow maximizes a window. -func (s *Service) MaximizeWindow(name string) error { - windows := s.app.Window().GetAll() - for _, w := range windows { - if wv, ok := w.(*application.WebviewWindow); ok { - if wv.Name() == name { - wv.Maximise() - return nil - } - } - } - return fmt.Errorf("window not found: %s", name) -} - -// RestoreWindow restores a maximized/minimized window. -func (s *Service) RestoreWindow(name string) error { - windows := s.app.Window().GetAll() - for _, w := range windows { - if wv, ok := w.(*application.WebviewWindow); ok { - if wv.Name() == name { - wv.Restore() - return nil - } - } - } - return fmt.Errorf("window not found: %s", name) -} - -// MinimizeWindow minimizes a window. -func (s *Service) MinimizeWindow(name string) error { - windows := s.app.Window().GetAll() - for _, w := range windows { - if wv, ok := w.(*application.WebviewWindow); ok { - if wv.Name() == name { - wv.Minimise() - return nil - } - } - } - return fmt.Errorf("window not found: %s", name) -} - -// FocusWindow brings a window to the front. -func (s *Service) FocusWindow(name string) error { - windows := s.app.Window().GetAll() - for _, w := range windows { - if wv, ok := w.(*application.WebviewWindow); ok { - if wv.Name() == name { - wv.Focus() - return nil - } - } - } - return fmt.Errorf("window not found: %s", name) -} - -// ResetWindowState clears saved window positions. -func (s *Service) ResetWindowState() error { - if s.windowStates != nil { - return s.windowStates.Clear() - } - return nil -} - -// GetSavedWindowStates returns all saved window states. -func (s *Service) GetSavedWindowStates() map[string]*WindowState { - if s.windowStates == nil { - return nil - } - - result := make(map[string]*WindowState) - for _, name := range s.windowStates.ListStates() { - result[name] = s.windowStates.GetState(name) - } - return result -} - // CreateWindowOptions contains options for creating a new window. type CreateWindowOptions struct { Name string `json:"name"` @@ -468,41 +351,16 @@ func (s *Service) CreateWindow(opts CreateWindowOptions) (*WindowInfo, error) { if opts.Name == "" { return nil, fmt.Errorf("window name is required") } - - // Set defaults - if opts.Width == 0 { - opts.Width = 800 + err := s.OpenWindow( + window.WithName(opts.Name), + window.WithTitle(opts.Title), + window.WithURL(opts.URL), + window.WithSize(opts.Width, opts.Height), + window.WithPosition(opts.X, opts.Y), + ) + if err != nil { + return nil, err } - if opts.Height == 0 { - opts.Height = 600 - } - if opts.URL == "" { - opts.URL = "/" - } - if opts.Title == "" { - opts.Title = opts.Name - } - - wailsOpts := application.WebviewWindowOptions{ - Name: opts.Name, - Title: opts.Title, - URL: opts.URL, - Width: opts.Width, - Height: opts.Height, - X: opts.X, - Y: opts.Y, - } - - window := s.app.Window().NewWithOptions(wailsOpts) - if window == nil { - return nil, fmt.Errorf("failed to create window") - } - - // Track window state - if s.windowStates != nil { - s.trackWindowState(opts.Name, window) - } - return &WindowInfo{ Name: opts.Name, X: opts.X, @@ -512,85 +370,112 @@ func (s *Service) CreateWindow(opts CreateWindowOptions) (*WindowInfo, error) { }, nil } -// CloseWindow closes a window by name. -func (s *Service) CloseWindow(name string) error { - windows := s.app.Window().GetAll() - for _, w := range windows { - if wv, ok := w.(*application.WebviewWindow); ok { - if wv.Name() == name { - wv.Close() - return nil +// --- Layout delegation --- + +// SaveLayout saves the current window arrangement as a named layout. +func (s *Service) SaveLayout(name string) error { + if s.windows == nil { + return fmt.Errorf("window manager not initialized") + } + states := make(map[string]window.WindowState) + for _, n := range s.windows.List() { + if pw, ok := s.windows.Get(n); ok { + x, y := pw.Position() + w, h := pw.Size() + states[n] = window.WindowState{X: x, Y: y, Width: w, Height: h, Maximized: pw.IsMaximised()} + } + } + return s.windows.Layout().SaveLayout(name, states) +} + +// RestoreLayout applies a saved layout. +func (s *Service) RestoreLayout(name string) error { + if s.windows == nil { + return fmt.Errorf("window manager not initialized") + } + layout, ok := s.windows.Layout().GetLayout(name) + if !ok { + return fmt.Errorf("layout not found: %s", name) + } + for wName, state := range layout.Windows { + if pw, ok := s.windows.Get(wName); ok { + pw.SetPosition(state.X, state.Y) + pw.SetSize(state.Width, state.Height) + if state.Maximized { + pw.Maximise() + } else { + pw.Restore() } } } - return fmt.Errorf("window not found: %s", name) + return nil } -// SetWindowVisibility shows or hides a window. -func (s *Service) SetWindowVisibility(name string, visible bool) error { - windows := s.app.Window().GetAll() - for _, w := range windows { - if wv, ok := w.(*application.WebviewWindow); ok { - if wv.Name() == name { - if visible { - wv.Show() - } else { - wv.Hide() - } - return nil - } - } +// ListLayouts returns all saved layout names with metadata. +func (s *Service) ListLayouts() []window.LayoutInfo { + if s.windows == nil { + return nil } - return fmt.Errorf("window not found: %s", name) + return s.windows.Layout().ListLayouts() } -// SetWindowAlwaysOnTop sets whether a window stays on top of other windows. -func (s *Service) SetWindowAlwaysOnTop(name string, alwaysOnTop bool) error { - windows := s.app.Window().GetAll() - for _, w := range windows { - if wv, ok := w.(*application.WebviewWindow); ok { - if wv.Name() == name { - wv.SetAlwaysOnTop(alwaysOnTop) - return nil - } - } +// DeleteLayout removes a saved layout by name. +func (s *Service) DeleteLayout(name string) error { + if s.windows == nil { + return fmt.Errorf("window manager not initialized") } - return fmt.Errorf("window not found: %s", name) + s.windows.Layout().DeleteLayout(name) + return nil } -// SetWindowTitle changes a window's title. -func (s *Service) SetWindowTitle(name string, title string) error { - windows := s.app.Window().GetAll() - for _, w := range windows { - if wv, ok := w.(*application.WebviewWindow); ok { - if wv.Name() == name { - wv.SetTitle(title) - return nil - } - } +// GetLayout returns a specific layout by name. +func (s *Service) GetLayout(name string) *window.Layout { + if s.windows == nil { + return nil } - return fmt.Errorf("window not found: %s", name) -} - -// SetWindowFullscreen sets a window to fullscreen mode. -func (s *Service) SetWindowFullscreen(name string, fullscreen bool) error { - windows := s.app.Window().GetAll() - for _, w := range windows { - if wv, ok := w.(*application.WebviewWindow); ok { - if wv.Name() == name { - if fullscreen { - wv.Fullscreen() - } else { - wv.UnFullscreen() - } - return nil - } - } + layout, ok := s.windows.Layout().GetLayout(name) + if !ok { + return nil } - return fmt.Errorf("window not found: %s", name) + return &layout } -// WorkArea represents usable screen space (excluding dock, menubar, etc). +// --- Tiling/snapping delegation --- + +// TileWindows arranges windows in a tiled layout. +func (s *Service) TileWindows(mode window.TileMode, windowNames []string) error { + return s.windows.TileWindows(mode, windowNames, 1920, 1080) // TODO: use actual screen size +} + +// SnapWindow snaps a window to a screen edge or corner. +func (s *Service) SnapWindow(name string, position window.SnapPosition) error { + return s.windows.SnapWindow(name, position, 1920, 1080) // TODO: use actual screen size +} + +// StackWindows arranges windows in a cascade pattern. +func (s *Service) StackWindows(windowNames []string, offsetX, offsetY int) error { + return s.windows.StackWindows(windowNames, offsetX, offsetY) +} + +// ApplyWorkflowLayout applies a predefined layout for a specific workflow. +func (s *Service) ApplyWorkflowLayout(workflow window.WorkflowLayout) error { + return s.windows.ApplyWorkflow(workflow, s.windows.List(), 1920, 1080) +} + +// --- Screen queries (remain in display — use application.Get() directly) --- + +// ScreenInfo contains information about a display screen. +type ScreenInfo struct { + ID string `json:"id"` + Name string `json:"name"` + X int `json:"x"` + Y int `json:"y"` + Width int `json:"width"` + Height int `json:"height"` + Primary bool `json:"primary"` +} + +// WorkArea represents usable screen space. type WorkArea struct { ScreenID string `json:"screenId"` X int `json:"x"` @@ -599,18 +484,41 @@ type WorkArea struct { Height int `json:"height"` } +// GetScreens returns information about all available screens. +func (s *Service) GetScreens() []ScreenInfo { + app := application.Get() + if app == nil || app.Screen == nil { + return nil + } + screens := app.Screen.GetAll() + if screens == nil { + return nil + } + result := make([]ScreenInfo, 0, len(screens)) + for _, screen := range screens { + result = append(result, ScreenInfo{ + ID: screen.ID, + Name: screen.Name, + X: screen.Bounds.X, + Y: screen.Bounds.Y, + Width: screen.Bounds.Width, + Height: screen.Bounds.Height, + Primary: screen.IsPrimary, + }) + } + return result +} + // GetWorkAreas returns the usable work area for all screens. func (s *Service) GetWorkAreas() []WorkArea { app := application.Get() if app == nil || app.Screen == nil { return nil } - screens := app.Screen.GetAll() if screens == nil { return nil } - result := make([]WorkArea, 0, len(screens)) for _, screen := range screens { result = append(result, WorkArea{ @@ -624,117 +532,24 @@ func (s *Service) GetWorkAreas() []WorkArea { return result } -// GetFocusedWindow returns the name of the currently focused window, or empty if none. -func (s *Service) GetFocusedWindow() string { - windows := s.app.Window().GetAll() - for _, w := range windows { - if wv, ok := w.(*application.WebviewWindow); ok { - if wv.IsFocused() { - return wv.Name() - } +// GetPrimaryScreen returns information about the primary screen. +func (s *Service) GetPrimaryScreen() (*ScreenInfo, error) { + app := application.Get() + if app == nil || app.Screen == nil { + return nil, fmt.Errorf("screen service not available") + } + screens := app.Screen.GetAll() + for _, screen := range screens { + if screen.IsPrimary { + return &ScreenInfo{ + ID: screen.ID, Name: screen.Name, + X: screen.Bounds.X, Y: screen.Bounds.Y, + Width: screen.Bounds.Width, Height: screen.Bounds.Height, + Primary: true, + }, nil } } - return "" -} - -// SaveLayout saves the current window arrangement as a named layout. -func (s *Service) SaveLayout(name string) error { - if s.layouts == nil { - return fmt.Errorf("layout manager not initialized") - } - - // Capture current window states - windowStates := make(map[string]WindowState) - windows := s.app.Window().GetAll() - for _, w := range windows { - if wv, ok := w.(*application.WebviewWindow); ok { - x, y := wv.Position() - width, height := wv.Size() - windowStates[wv.Name()] = WindowState{ - X: x, - Y: y, - Width: width, - Height: height, - Maximized: wv.IsMaximised(), - } - } - } - - return s.layouts.SaveLayout(name, windowStates) -} - -// RestoreLayout applies a saved layout, positioning all windows. -func (s *Service) RestoreLayout(name string) error { - if s.layouts == nil { - return fmt.Errorf("layout manager not initialized") - } - - layout := s.layouts.GetLayout(name) - if layout == nil { - return fmt.Errorf("layout not found: %s", name) - } - - // Apply saved positions to existing windows - windows := s.app.Window().GetAll() - for _, w := range windows { - if wv, ok := w.(*application.WebviewWindow); ok { - if state, exists := layout.Windows[wv.Name()]; exists { - wv.SetPosition(state.X, state.Y) - wv.SetSize(state.Width, state.Height) - if state.Maximized { - wv.Maximise() - } else { - wv.Restore() - } - } - } - } - - return nil -} - -// ListLayouts returns all saved layout names with metadata. -func (s *Service) ListLayouts() []LayoutInfo { - if s.layouts == nil { - return nil - } - return s.layouts.ListLayouts() -} - -// DeleteLayout removes a saved layout by name. -func (s *Service) DeleteLayout(name string) error { - if s.layouts == nil { - return fmt.Errorf("layout manager not initialized") - } - return s.layouts.DeleteLayout(name) -} - -// GetLayout returns a specific layout by name. -func (s *Service) GetLayout(name string) *Layout { - if s.layouts == nil { - return nil - } - return s.layouts.GetLayout(name) -} - -// GetEventManager returns the event manager for WebSocket event subscriptions. -func (s *Service) GetEventManager() *WSEventManager { - return s.events -} - -// GetWindowTitle returns the title of a window by name. -// Note: Wails v3 doesn't expose a title getter, so we track it ourselves or return the name. -func (s *Service) GetWindowTitle(name string) (string, error) { - windows := s.app.Window().GetAll() - for _, w := range windows { - if wv, ok := w.(*application.WebviewWindow); ok { - if wv.Name() == name { - // Window name as fallback since Wails v3 doesn't have a title getter - return name, nil - } - } - } - return "", fmt.Errorf("window not found: %s", name) + return nil, fmt.Errorf("no primary screen found") } // GetScreen returns information about a specific screen by ID. @@ -743,17 +558,13 @@ func (s *Service) GetScreen(id string) (*ScreenInfo, error) { if app == nil || app.Screen == nil { return nil, fmt.Errorf("screen service not available") } - screens := app.Screen.GetAll() for _, screen := range screens { if screen.ID == id { return &ScreenInfo{ - ID: screen.ID, - Name: screen.Name, - X: screen.Bounds.X, - Y: screen.Bounds.Y, - Width: screen.Bounds.Width, - Height: screen.Bounds.Height, + ID: screen.ID, Name: screen.Name, + X: screen.Bounds.X, Y: screen.Bounds.Y, + Width: screen.Bounds.Width, Height: screen.Bounds.Height, Primary: screen.IsPrimary, }, nil } @@ -761,49 +572,21 @@ func (s *Service) GetScreen(id string) (*ScreenInfo, error) { return nil, fmt.Errorf("screen not found: %s", id) } -// GetPrimaryScreen returns information about the primary screen. -func (s *Service) GetPrimaryScreen() (*ScreenInfo, error) { - app := application.Get() - if app == nil || app.Screen == nil { - return nil, fmt.Errorf("screen service not available") - } - - screens := app.Screen.GetAll() - for _, screen := range screens { - if screen.IsPrimary { - return &ScreenInfo{ - ID: screen.ID, - Name: screen.Name, - X: screen.Bounds.X, - Y: screen.Bounds.Y, - Width: screen.Bounds.Width, - Height: screen.Bounds.Height, - Primary: true, - }, nil - } - } - return nil, fmt.Errorf("no primary screen found") -} - // GetScreenAtPoint returns the screen containing a specific point. func (s *Service) GetScreenAtPoint(x, y int) (*ScreenInfo, error) { app := application.Get() if app == nil || app.Screen == nil { return nil, fmt.Errorf("screen service not available") } - screens := app.Screen.GetAll() for _, screen := range screens { bounds := screen.Bounds if x >= bounds.X && x < bounds.X+bounds.Width && y >= bounds.Y && y < bounds.Y+bounds.Height { return &ScreenInfo{ - ID: screen.ID, - Name: screen.Name, - X: bounds.X, - Y: bounds.Y, - Width: bounds.Width, - Height: bounds.Height, + ID: screen.ID, Name: screen.Name, + X: bounds.X, Y: bounds.Y, + Width: bounds.Width, Height: bounds.Height, Primary: screen.IsPrimary, }, nil } @@ -813,481 +596,154 @@ func (s *Service) GetScreenAtPoint(x, y int) (*ScreenInfo, error) { // GetScreenForWindow returns the screen containing a specific window. func (s *Service) GetScreenForWindow(name string) (*ScreenInfo, error) { - // Get window position info, err := s.GetWindowInfo(name) if err != nil { return nil, err } - - // Find screen at window center centerX := info.X + info.Width/2 centerY := info.Y + info.Height/2 - return s.GetScreenAtPoint(centerX, centerY) } -// SetWindowBackgroundColour sets the background color of a window with alpha for transparency. -// Note: On Windows, only alpha 0 or 255 are supported. Other values treated as 255. -func (s *Service) SetWindowBackgroundColour(name string, r, g, b, a uint8) error { - windows := s.app.Window().GetAll() - for _, w := range windows { - if wv, ok := w.(*application.WebviewWindow); ok { - if wv.Name() == name { - wv.SetBackgroundColour(application.RGBA{Red: r, Green: g, Blue: b, Alpha: a}) - return nil +// ShowEnvironmentDialog displays environment information. +func (s *Service) ShowEnvironmentDialog() { + envInfo := s.app.Env().Info() + details := "Environment Information:\n\n" + details += fmt.Sprintf("Operating System: %s\n", envInfo.OS) + details += fmt.Sprintf("Architecture: %s\n", envInfo.Arch) + details += fmt.Sprintf("Debug Mode: %t\n\n", envInfo.Debug) + details += fmt.Sprintf("Dark Mode: %t\n\n", s.app.Env().IsDarkMode()) + details += "Platform Information:" + for key, value := range envInfo.PlatformInfo { + details += fmt.Sprintf("\n%s: %v", key, value) + } + if envInfo.OSInfo != nil { + details += fmt.Sprintf("\n\nOS Details:\nName: %s\nVersion: %s", + envInfo.OSInfo.Name, envInfo.OSInfo.Version) + } + dialog := s.app.Dialog().Info() + dialog.SetTitle("Environment Information") + dialog.SetMessage(details) + dialog.Show() +} + +// GetEventManager returns the event manager for WebSocket event subscriptions. +func (s *Service) GetEventManager() *WSEventManager { + return s.events +} + +// --- Menu (handlers stay in display, structure delegated to menu.Manager) --- + +func (s *Service) buildMenu() { + items := []menu.MenuItem{ + {Role: ptr(menu.RoleAppMenu)}, + {Role: ptr(menu.RoleFileMenu)}, + {Role: ptr(menu.RoleViewMenu)}, + {Role: ptr(menu.RoleEditMenu)}, + {Label: "Workspace", Children: []menu.MenuItem{ + {Label: "New...", OnClick: s.handleNewWorkspace}, + {Label: "List", OnClick: s.handleListWorkspaces}, + }}, + {Label: "Developer", Children: []menu.MenuItem{ + {Label: "New File", Accelerator: "CmdOrCtrl+N", OnClick: s.handleNewFile}, + {Label: "Open File...", Accelerator: "CmdOrCtrl+O", OnClick: s.handleOpenFile}, + {Label: "Save", Accelerator: "CmdOrCtrl+S", OnClick: s.handleSaveFile}, + {Type: "separator"}, + {Label: "Editor", OnClick: s.handleOpenEditor}, + {Label: "Terminal", OnClick: s.handleOpenTerminal}, + {Type: "separator"}, + {Label: "Run", Accelerator: "CmdOrCtrl+R", OnClick: s.handleRun}, + {Label: "Build", Accelerator: "CmdOrCtrl+B", OnClick: s.handleBuild}, + }}, + {Role: ptr(menu.RoleWindowMenu)}, + {Role: ptr(menu.RoleHelpMenu)}, + } + + // On non-macOS, remove the AppMenu role + if runtime.GOOS != "darwin" { + items = items[1:] // skip AppMenu + } + + s.menus.SetApplicationMenu(items) +} + +func ptr[T any](v T) *T { return &v } + +// --- Menu handler methods --- + +func (s *Service) handleNewWorkspace() { + _ = s.OpenWindow(window.WithName("workspace-new"), window.WithTitle("New Workspace"), + window.WithURL("/workspace/new"), window.WithSize(500, 400)) +} + +func (s *Service) handleListWorkspaces() { + ws := s.Core().Service("workspace") + if ws == nil { + return + } + lister, ok := ws.(interface{ ListWorkspaces() []string }) + if !ok { + return + } + _ = lister.ListWorkspaces() +} + +func (s *Service) handleNewFile() { + _ = s.OpenWindow(window.WithName("editor"), window.WithTitle("New File - Editor"), + window.WithURL("/#/developer/editor?new=true"), window.WithSize(1200, 800)) +} + +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 + } + _ = s.OpenWindow(window.WithName("editor"), window.WithTitle(result+" - Editor"), + window.WithURL("/#/developer/editor?file="+result), window.WithSize(1200, 800)) +} + +func (s *Service) handleSaveFile() { s.app.Event().Emit("ide:save") } +func (s *Service) handleOpenEditor() { + _ = s.OpenWindow(window.WithName("editor"), window.WithTitle("Editor"), + window.WithURL("/#/developer/editor"), window.WithSize(1200, 800)) +} +func (s *Service) handleOpenTerminal() { + _ = s.OpenWindow(window.WithName("terminal"), window.WithTitle("Terminal"), + window.WithURL("/#/developer/terminal"), window.WithSize(800, 500)) +} +func (s *Service) handleRun() { s.app.Event().Emit("ide:run") } +func (s *Service) handleBuild() { s.app.Event().Emit("ide:build") } + +// --- Tray (setup delegated to systray.Manager) --- + +func (s *Service) setupTray() { + _ = s.tray.Setup("Core", "Core") + s.tray.RegisterCallback("open-desktop", func() { + for _, name := range s.windows.List() { + if pw, ok := s.windows.Get(name); ok { + pw.Show() } } - } - return fmt.Errorf("window not found: %s", name) -} - -// TileMode represents different tiling arrangements. -type TileMode string - -const ( - TileModeLeft TileMode = "left" - TileModeRight TileMode = "right" - TileModeTop TileMode = "top" - TileModeBottom TileMode = "bottom" - TileModeTopLeft TileMode = "top-left" - TileModeTopRight TileMode = "top-right" - TileModeBottomLeft TileMode = "bottom-left" - TileModeBottomRight TileMode = "bottom-right" - TileModeGrid TileMode = "grid" -) - -// TileWindows arranges windows in a tiled layout. -// mode can be: left, right, top, bottom, top-left, top-right, bottom-left, bottom-right, grid -// If windowNames is empty, tiles all windows. -func (s *Service) TileWindows(mode TileMode, windowNames []string) error { - // Get work area for primary screen - workAreas := s.GetWorkAreas() - if len(workAreas) == 0 { - return fmt.Errorf("no work areas available") - } - wa := workAreas[0] // Use primary screen work area - - // Get windows to tile - allWindows := s.app.Window().GetAll() - var windowsToTile []*application.WebviewWindow - - if len(windowNames) == 0 { - // Tile all windows - for _, w := range allWindows { - if wv, ok := w.(*application.WebviewWindow); ok { - windowsToTile = append(windowsToTile, wv) + }) + s.tray.RegisterCallback("close-desktop", func() { + for _, name := range s.windows.List() { + if pw, ok := s.windows.Get(name); ok { + pw.Hide() } } - } else { - // Tile specific windows - nameSet := make(map[string]bool) - for _, name := range windowNames { - nameSet[name] = true - } - for _, w := range allWindows { - if wv, ok := w.(*application.WebviewWindow); ok { - if nameSet[wv.Name()] { - windowsToTile = append(windowsToTile, wv) - } - } - } - } - - if len(windowsToTile) == 0 { - return fmt.Errorf("no windows to tile") - } - - switch mode { - case TileModeLeft: - // All windows on left half - for _, wv := range windowsToTile { - wv.SetPosition(wa.X, wa.Y) - wv.SetSize(wa.Width/2, wa.Height) - } - - case TileModeRight: - // All windows on right half - for _, wv := range windowsToTile { - wv.SetPosition(wa.X+wa.Width/2, wa.Y) - wv.SetSize(wa.Width/2, wa.Height) - } - - case TileModeTop: - // All windows on top half - for _, wv := range windowsToTile { - wv.SetPosition(wa.X, wa.Y) - wv.SetSize(wa.Width, wa.Height/2) - } - - case TileModeBottom: - // All windows on bottom half - for _, wv := range windowsToTile { - wv.SetPosition(wa.X, wa.Y+wa.Height/2) - wv.SetSize(wa.Width, wa.Height/2) - } - - case TileModeTopLeft: - for _, wv := range windowsToTile { - wv.SetPosition(wa.X, wa.Y) - wv.SetSize(wa.Width/2, wa.Height/2) - } - - case TileModeTopRight: - for _, wv := range windowsToTile { - wv.SetPosition(wa.X+wa.Width/2, wa.Y) - wv.SetSize(wa.Width/2, wa.Height/2) - } - - case TileModeBottomLeft: - for _, wv := range windowsToTile { - wv.SetPosition(wa.X, wa.Y+wa.Height/2) - wv.SetSize(wa.Width/2, wa.Height/2) - } - - case TileModeBottomRight: - for _, wv := range windowsToTile { - wv.SetPosition(wa.X+wa.Width/2, wa.Y+wa.Height/2) - wv.SetSize(wa.Width/2, wa.Height/2) - } - - case TileModeGrid: - // Arrange in a grid - count := len(windowsToTile) - cols := 1 - rows := 1 - // Calculate optimal grid - for cols*rows < count { - if cols <= rows { - cols++ - } else { - rows++ - } - } - - cellWidth := wa.Width / cols - cellHeight := wa.Height / rows - - for i, wv := range windowsToTile { - col := i % cols - row := i / cols - wv.SetPosition(wa.X+col*cellWidth, wa.Y+row*cellHeight) - wv.SetSize(cellWidth, cellHeight) - } - - default: - return fmt.Errorf("unknown tile mode: %s", mode) - } - - return nil -} - -// SnapPosition represents positions for snapping windows. -type SnapPosition string - -const ( - SnapLeft SnapPosition = "left" - SnapRight SnapPosition = "right" - SnapTop SnapPosition = "top" - SnapBottom SnapPosition = "bottom" - SnapTopLeft SnapPosition = "top-left" - SnapTopRight SnapPosition = "top-right" - SnapBottomLeft SnapPosition = "bottom-left" - SnapBottomRight SnapPosition = "bottom-right" - SnapCenter SnapPosition = "center" -) - -// SnapWindow snaps a window to a screen edge or corner. -func (s *Service) SnapWindow(name string, position SnapPosition) error { - // Get window - window, err := s.GetWindowInfo(name) - if err != nil { - return err - } - - // Get screen for window - screen, err := s.GetScreenForWindow(name) - if err != nil { - return err - } - - // Get work area for this screen - workAreas := s.GetWorkAreas() - var wa *WorkArea - for _, area := range workAreas { - if area.ScreenID == screen.ID { - wa = &area - break - } - } - if wa == nil { - // Fallback to screen bounds - wa = &WorkArea{ - ScreenID: screen.ID, - X: screen.X, - Y: screen.Y, - Width: screen.Width, - Height: screen.Height, - } - } - - // Calculate position based on snap position - var x, y, width, height int - - switch position { - case SnapLeft: - x = wa.X - y = wa.Y - width = wa.Width / 2 - height = wa.Height - - case SnapRight: - x = wa.X + wa.Width/2 - y = wa.Y - width = wa.Width / 2 - height = wa.Height - - case SnapTop: - x = wa.X - y = wa.Y - width = wa.Width - height = wa.Height / 2 - - case SnapBottom: - x = wa.X - y = wa.Y + wa.Height/2 - width = wa.Width - height = wa.Height / 2 - - case SnapTopLeft: - x = wa.X - y = wa.Y - width = wa.Width / 2 - height = wa.Height / 2 - - case SnapTopRight: - x = wa.X + wa.Width/2 - y = wa.Y - width = wa.Width / 2 - height = wa.Height / 2 - - case SnapBottomLeft: - x = wa.X - y = wa.Y + wa.Height/2 - width = wa.Width / 2 - height = wa.Height / 2 - - case SnapBottomRight: - x = wa.X + wa.Width/2 - y = wa.Y + wa.Height/2 - width = wa.Width / 2 - height = wa.Height / 2 - - case SnapCenter: - // Center the window without resizing - x = wa.X + (wa.Width-window.Width)/2 - y = wa.Y + (wa.Height-window.Height)/2 - width = window.Width - height = window.Height - - default: - return fmt.Errorf("unknown snap position: %s", position) - } - - return s.SetWindowBounds(name, x, y, width, height) -} - -// StackWindows arranges windows in a cascade (stacked) pattern. -// Each window is offset by the given amount from the previous one. -func (s *Service) StackWindows(windowNames []string, offsetX, offsetY int) error { - if offsetX == 0 { - offsetX = 30 - } - if offsetY == 0 { - offsetY = 30 - } - - // Get work area for primary screen - workAreas := s.GetWorkAreas() - if len(workAreas) == 0 { - return fmt.Errorf("no work areas available") - } - wa := workAreas[0] - - // Get windows to stack - allWindows := s.app.Window().GetAll() - var windowsToStack []*application.WebviewWindow - - if len(windowNames) == 0 { - for _, w := range allWindows { - if wv, ok := w.(*application.WebviewWindow); ok { - windowsToStack = append(windowsToStack, wv) - } - } - } else { - nameSet := make(map[string]bool) - for _, name := range windowNames { - nameSet[name] = true - } - for _, w := range allWindows { - if wv, ok := w.(*application.WebviewWindow); ok { - if nameSet[wv.Name()] { - windowsToStack = append(windowsToStack, wv) - } - } - } - } - - if len(windowsToStack) == 0 { - return fmt.Errorf("no windows to stack") - } - - // Calculate window size (leave room for cascade) - maxOffset := (len(windowsToStack) - 1) * offsetX - windowWidth := wa.Width - maxOffset - 50 - maxOffsetY := (len(windowsToStack) - 1) * offsetY - windowHeight := wa.Height - maxOffsetY - 50 - - // Ensure minimum size - if windowWidth < 400 { - windowWidth = 400 - } - if windowHeight < 300 { - windowHeight = 300 - } - - // Position each window - for i, wv := range windowsToStack { - x := wa.X + (i * offsetX) - y := wa.Y + (i * offsetY) - wv.SetPosition(x, y) - wv.SetSize(windowWidth, windowHeight) - wv.Focus() // Bring to front in order - } - - return nil -} - -// WorkflowType represents predefined workflow layouts. -type WorkflowType string - -const ( - WorkflowCoding WorkflowType = "coding" - WorkflowDebugging WorkflowType = "debugging" - WorkflowPresenting WorkflowType = "presenting" - WorkflowSideBySide WorkflowType = "side-by-side" -) - -// ApplyWorkflowLayout applies a predefined layout for a specific workflow. -func (s *Service) ApplyWorkflowLayout(workflow WorkflowType) error { - switch workflow { - case WorkflowCoding: - // Main editor takes 70% left, tools on right 30% - return s.applyWorkflowCoding() - - case WorkflowDebugging: - // Code on top 60%, debug output on bottom 40% - return s.applyWorkflowDebugging() - - case WorkflowPresenting: - // Single window maximized - return s.applyWorkflowPresenting() - - case WorkflowSideBySide: - // Two windows side by side 50/50 - return s.TileWindows(TileModeGrid, nil) - - default: - return fmt.Errorf("unknown workflow: %s", workflow) - } -} - -func (s *Service) applyWorkflowCoding() error { - workAreas := s.GetWorkAreas() - if len(workAreas) == 0 { - return fmt.Errorf("no work areas available") - } - wa := workAreas[0] - - windows := s.app.Window().GetAll() - if len(windows) == 0 { - return fmt.Errorf("no windows to arrange") - } - - // First window gets 70% width on left - if len(windows) >= 1 { - if wv, ok := windows[0].(*application.WebviewWindow); ok { - wv.SetPosition(wa.X, wa.Y) - wv.SetSize(wa.Width*70/100, wa.Height) - } - } - - // Remaining windows stack on right 30% - rightX := wa.X + wa.Width*70/100 - rightWidth := wa.Width * 30 / 100 - remainingHeight := wa.Height / max(1, len(windows)-1) - - for i := 1; i < len(windows); i++ { - if wv, ok := windows[i].(*application.WebviewWindow); ok { - wv.SetPosition(rightX, wa.Y+(i-1)*remainingHeight) - wv.SetSize(rightWidth, remainingHeight) - } - } - - return nil -} - -func (s *Service) applyWorkflowDebugging() error { - workAreas := s.GetWorkAreas() - if len(workAreas) == 0 { - return fmt.Errorf("no work areas available") - } - wa := workAreas[0] - - windows := s.app.Window().GetAll() - if len(windows) == 0 { - return fmt.Errorf("no windows to arrange") - } - - // First window gets top 60% - if len(windows) >= 1 { - if wv, ok := windows[0].(*application.WebviewWindow); ok { - wv.SetPosition(wa.X, wa.Y) - wv.SetSize(wa.Width, wa.Height*60/100) - } - } - - // Remaining windows split bottom 40% - bottomY := wa.Y + wa.Height*60/100 - bottomHeight := wa.Height * 40 / 100 - remainingWidth := wa.Width / max(1, len(windows)-1) - - for i := 1; i < len(windows); i++ { - if wv, ok := windows[i].(*application.WebviewWindow); ok { - wv.SetPosition(wa.X+(i-1)*remainingWidth, bottomY) - wv.SetSize(remainingWidth, bottomHeight) - } - } - - return nil -} - -func (s *Service) applyWorkflowPresenting() error { - windows := s.app.Window().GetAll() - if len(windows) == 0 { - return fmt.Errorf("no windows to arrange") - } - - // Maximize first window, minimize others - for i, w := range windows { - if wv, ok := w.(*application.WebviewWindow); ok { - if i == 0 { - wv.Maximise() - wv.Focus() - } else { - wv.Minimise() - } - } - } - - return nil + }) + s.tray.RegisterCallback("env-info", func() { s.ShowEnvironmentDialog() }) + s.tray.RegisterCallback("quit", func() { s.app.Quit() }) + _ = s.tray.SetMenu([]systray.TrayMenuItem{ + {Label: "Open Desktop", ActionID: "open-desktop"}, + {Label: "Close Desktop", ActionID: "close-desktop"}, + {Type: "separator"}, + {Label: "Environment Info", ActionID: "env-info"}, + {Type: "separator"}, + {Label: "Quit", ActionID: "quit"}, + }) } diff --git a/pkg/display/display_test.go b/pkg/display/display_test.go index c02d1df..80b2fd0 100644 --- a/pkg/display/display_test.go +++ b/pkg/display/display_test.go @@ -3,19 +3,190 @@ package display import ( "testing" - "forge.lthn.ai/core/gui/pkg/core" + "forge.lthn.ai/core/go/pkg/core" + "forge.lthn.ai/core/gui/pkg/menu" + "forge.lthn.ai/core/gui/pkg/systray" + "forge.lthn.ai/core/gui/pkg/window" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/wailsapp/wails/v3/pkg/application" ) -// newTestCore creates a new core instance with essential services for testing. +// --- Mock platform implementations for sub-packages --- + +// displayMockPlatformWindow implements window.PlatformWindow for display tests. +type displayMockPlatformWindow struct { + name, title, url string + width, height, x, y int + maximised, focused bool + visible, alwaysOnTop bool + closed bool + eventHandlers []func(window.WindowEvent) +} + +func (w *displayMockPlatformWindow) Name() string { return w.name } +func (w *displayMockPlatformWindow) Position() (int, int) { return w.x, w.y } +func (w *displayMockPlatformWindow) Size() (int, int) { return w.width, w.height } +func (w *displayMockPlatformWindow) IsMaximised() bool { return w.maximised } +func (w *displayMockPlatformWindow) IsFocused() bool { return w.focused } +func (w *displayMockPlatformWindow) SetTitle(title string) { w.title = title } +func (w *displayMockPlatformWindow) SetPosition(x, y int) { w.x = x; w.y = y } +func (w *displayMockPlatformWindow) SetSize(width, height int) { + w.width = width + w.height = height +} +func (w *displayMockPlatformWindow) SetBackgroundColour(r, g, b, a uint8) {} +func (w *displayMockPlatformWindow) SetVisibility(visible bool) { w.visible = visible } +func (w *displayMockPlatformWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop } +func (w *displayMockPlatformWindow) Maximise() { w.maximised = true } +func (w *displayMockPlatformWindow) Restore() { w.maximised = false } +func (w *displayMockPlatformWindow) Minimise() {} +func (w *displayMockPlatformWindow) Focus() { w.focused = true } +func (w *displayMockPlatformWindow) Close() { w.closed = true } +func (w *displayMockPlatformWindow) Show() { w.visible = true } +func (w *displayMockPlatformWindow) Hide() { w.visible = false } +func (w *displayMockPlatformWindow) Fullscreen() {} +func (w *displayMockPlatformWindow) UnFullscreen() {} +func (w *displayMockPlatformWindow) OnWindowEvent(handler func(window.WindowEvent)) { + w.eventHandlers = append(w.eventHandlers, handler) +} + +// displayMockWindowPlatform implements window.Platform for display tests. +type displayMockWindowPlatform struct { + windows []*displayMockPlatformWindow +} + +func (p *displayMockWindowPlatform) CreateWindow(opts window.PlatformWindowOptions) window.PlatformWindow { + w := &displayMockPlatformWindow{ + name: opts.Name, title: opts.Title, url: opts.URL, + width: opts.Width, height: opts.Height, + x: opts.X, y: opts.Y, + } + p.windows = append(p.windows, w) + return w +} + +func (p *displayMockWindowPlatform) GetWindows() []window.PlatformWindow { + out := make([]window.PlatformWindow, len(p.windows)) + for i, w := range p.windows { + out[i] = w + } + return out +} + +// displayMockSystrayPlatform implements systray.Platform for display tests. +type displayMockSystrayPlatform struct { + trays []*displayMockTray + menus []*displayMockSystrayMenu +} + +func (p *displayMockSystrayPlatform) NewTray() systray.PlatformTray { + t := &displayMockTray{} + p.trays = append(p.trays, t) + return t +} + +func (p *displayMockSystrayPlatform) NewMenu() systray.PlatformMenu { + m := &displayMockSystrayMenu{} + p.menus = append(p.menus, m) + return m +} + +type displayMockTray struct { + tooltip, label string + menu systray.PlatformMenu +} + +func (t *displayMockTray) SetIcon(data []byte) {} +func (t *displayMockTray) SetTemplateIcon(data []byte) {} +func (t *displayMockTray) SetTooltip(text string) { t.tooltip = text } +func (t *displayMockTray) SetLabel(text string) { t.label = text } +func (t *displayMockTray) SetMenu(m systray.PlatformMenu) { t.menu = m } +func (t *displayMockTray) AttachWindow(w systray.WindowHandle) {} + +type displayMockSystrayMenu struct { + items []string +} + +func (m *displayMockSystrayMenu) Add(label string) systray.PlatformMenuItem { + m.items = append(m.items, label) + return &displayMockSystrayMenuItem{} +} +func (m *displayMockSystrayMenu) AddSeparator() { m.items = append(m.items, "---") } + +type displayMockSystrayMenuItem struct{} + +func (mi *displayMockSystrayMenuItem) SetTooltip(text string) {} +func (mi *displayMockSystrayMenuItem) SetChecked(checked bool) {} +func (mi *displayMockSystrayMenuItem) SetEnabled(enabled bool) {} +func (mi *displayMockSystrayMenuItem) OnClick(fn func()) {} +func (mi *displayMockSystrayMenuItem) AddSubmenu() systray.PlatformMenu { + return &displayMockSystrayMenu{} +} + +// displayMockMenuPlatform implements menu.Platform for display tests. +type displayMockMenuPlatform struct { + appMenu menu.PlatformMenu +} + +func (p *displayMockMenuPlatform) NewMenu() menu.PlatformMenu { + return &displayMockMenu{} +} + +func (p *displayMockMenuPlatform) SetApplicationMenu(m menu.PlatformMenu) { + p.appMenu = m +} + +type displayMockMenu struct { + items []string +} + +func (m *displayMockMenu) Add(label string) menu.PlatformMenuItem { + m.items = append(m.items, label) + return &displayMockMenuItem{} +} +func (m *displayMockMenu) AddSeparator() { m.items = append(m.items, "---") } +func (m *displayMockMenu) AddSubmenu(label string) menu.PlatformMenu { + m.items = append(m.items, label) + return &displayMockMenu{} +} +func (m *displayMockMenu) AddRole(role menu.MenuRole) {} + +type displayMockMenuItem struct{} + +func (mi *displayMockMenuItem) SetAccelerator(accel string) menu.PlatformMenuItem { return mi } +func (mi *displayMockMenuItem) SetTooltip(text string) menu.PlatformMenuItem { return mi } +func (mi *displayMockMenuItem) SetChecked(checked bool) menu.PlatformMenuItem { return mi } +func (mi *displayMockMenuItem) SetEnabled(enabled bool) menu.PlatformMenuItem { return mi } +func (mi *displayMockMenuItem) OnClick(fn func()) menu.PlatformMenuItem { return mi } + +// --- Test helpers --- + +// newTestCore creates a new core instance for testing. func newTestCore(t *testing.T) *core.Core { coreInstance, err := core.New() require.NoError(t, err) return coreInstance } +// newServiceWithMocks creates a Service with mock sub-managers for testing. +// Uses a temp directory for state/layout persistence to avoid loading real saved state. +func newServiceWithMocks(t *testing.T) (*Service, *mockApp, *displayMockWindowPlatform) { + service, err := New() + require.NoError(t, err) + + mock := newMockApp() + service.app = mock + + wp := &displayMockWindowPlatform{} + service.windows = window.NewManagerWithDir(wp, t.TempDir()) + service.tray = systray.NewManager(&displayMockSystrayPlatform{}) + service.menus = menu.NewManager(&displayMockMenuPlatform{}) + + return service, mock, wp +} + +// --- Tests --- + func TestNew(t *testing.T) { t.Run("creates service successfully", func(t *testing.T) { service, err := New() @@ -59,578 +230,354 @@ func TestServiceName(t *testing.T) { assert.Equal(t, "forge.lthn.ai/core/gui/display", name) } -// --- Window Option Tests --- - -func TestWindowName(t *testing.T) { - t.Run("sets window name", func(t *testing.T) { - opt := WindowName("test-window") - window := &Window{} - - err := opt(window) - assert.NoError(t, err) - assert.Equal(t, "test-window", window.Name) - }) - - t.Run("sets empty name", func(t *testing.T) { - opt := WindowName("") - window := &Window{} - - err := opt(window) - assert.NoError(t, err) - assert.Equal(t, "", window.Name) - }) -} - -func TestWindowTitle(t *testing.T) { - t.Run("sets window title", func(t *testing.T) { - opt := WindowTitle("My Application") - window := &Window{} - - err := opt(window) - assert.NoError(t, err) - assert.Equal(t, "My Application", window.Title) - }) - - t.Run("sets title with special characters", func(t *testing.T) { - opt := WindowTitle("App - v1.0 (Beta)") - window := &Window{} - - err := opt(window) - assert.NoError(t, err) - assert.Equal(t, "App - v1.0 (Beta)", window.Title) - }) -} - -func TestWindowURL(t *testing.T) { - t.Run("sets window URL", func(t *testing.T) { - opt := WindowURL("/dashboard") - window := &Window{} - - err := opt(window) - assert.NoError(t, err) - assert.Equal(t, "/dashboard", window.URL) - }) - - t.Run("sets full URL", func(t *testing.T) { - opt := WindowURL("https://example.com/page") - window := &Window{} - - err := opt(window) - assert.NoError(t, err) - assert.Equal(t, "https://example.com/page", window.URL) - }) -} - -func TestWindowWidth(t *testing.T) { - t.Run("sets window width", func(t *testing.T) { - opt := WindowWidth(1024) - window := &Window{} - - err := opt(window) - assert.NoError(t, err) - assert.Equal(t, 1024, window.Width) - }) - - t.Run("sets zero width", func(t *testing.T) { - opt := WindowWidth(0) - window := &Window{} - - err := opt(window) - assert.NoError(t, err) - assert.Equal(t, 0, window.Width) - }) - - t.Run("sets large width", func(t *testing.T) { - opt := WindowWidth(3840) - window := &Window{} - - err := opt(window) - assert.NoError(t, err) - assert.Equal(t, 3840, window.Width) - }) -} - -func TestWindowHeight(t *testing.T) { - t.Run("sets window height", func(t *testing.T) { - opt := WindowHeight(768) - window := &Window{} - - err := opt(window) - assert.NoError(t, err) - assert.Equal(t, 768, window.Height) - }) - - t.Run("sets zero height", func(t *testing.T) { - opt := WindowHeight(0) - window := &Window{} - - err := opt(window) - assert.NoError(t, err) - assert.Equal(t, 0, window.Height) - }) -} - -func TestApplyOptions(t *testing.T) { - t.Run("applies no options", func(t *testing.T) { - window := applyOptions() - assert.NotNil(t, window) - assert.Equal(t, "", window.Name) - assert.Equal(t, "", window.Title) - assert.Equal(t, 0, window.Width) - assert.Equal(t, 0, window.Height) - }) - - t.Run("applies single option", func(t *testing.T) { - window := applyOptions(WindowTitle("Test")) - assert.NotNil(t, window) - assert.Equal(t, "Test", window.Title) - }) - - t.Run("applies multiple options", func(t *testing.T) { - window := applyOptions( - WindowName("main"), - WindowTitle("My App"), - WindowURL("/home"), - WindowWidth(1280), - WindowHeight(720), - ) - - assert.NotNil(t, window) - assert.Equal(t, "main", window.Name) - assert.Equal(t, "My App", window.Title) - assert.Equal(t, "/home", window.URL) - assert.Equal(t, 1280, window.Width) - assert.Equal(t, 720, window.Height) - }) - - t.Run("handles nil options slice", func(t *testing.T) { - window := applyOptions(nil...) - assert.NotNil(t, window) - }) - - t.Run("applies options in order", func(t *testing.T) { - // Later options should override earlier ones - window := applyOptions( - WindowTitle("First"), - WindowTitle("Second"), - ) - - assert.NotNil(t, window) - assert.Equal(t, "Second", window.Title) - }) -} - -// --- ActionOpenWindow Tests --- - -func TestActionOpenWindow(t *testing.T) { - t.Run("creates action with options", func(t *testing.T) { - action := ActionOpenWindow{ - WebviewWindowOptions: application.WebviewWindowOptions{ - Name: "test", - Title: "Test Window", - Width: 800, - Height: 600, - }, - } - - assert.Equal(t, "test", action.Name) - assert.Equal(t, "Test Window", action.Title) - assert.Equal(t, 800, action.Width) - assert.Equal(t, 600, action.Height) - }) -} - -// --- Tests with Mock App --- - -// newServiceWithMockApp creates a Service with a mock app for testing. -func newServiceWithMockApp(t *testing.T) (*Service, *mockApp) { - service, err := New() - require.NoError(t, err) - mock := newMockApp() - service.app = mock - return service, mock -} - -func TestOpenWindow(t *testing.T) { +func TestOpenWindow_Good(t *testing.T) { t.Run("creates window with default options", func(t *testing.T) { - service, mock := newServiceWithMockApp(t) + service, _, wp := newServiceWithMocks(t) err := service.OpenWindow() assert.NoError(t, err) - // Verify window was created - assert.Len(t, mock.windowManager.createdWindows, 1) - opts := mock.windowManager.createdWindows[0] - assert.Equal(t, "main", opts.Name) - assert.Equal(t, "Core", opts.Title) - assert.Equal(t, 1280, opts.Width) - assert.Equal(t, 800, opts.Height) - assert.Equal(t, "/", opts.URL) + // Verify window was created through the platform + assert.Len(t, wp.windows, 1) + assert.Equal(t, "main", wp.windows[0].name) + assert.Equal(t, "Core", wp.windows[0].title) + assert.Equal(t, 1280, wp.windows[0].width) + assert.Equal(t, 800, wp.windows[0].height) }) t.Run("creates window with custom options", func(t *testing.T) { - service, mock := newServiceWithMockApp(t) + service, _, wp := newServiceWithMocks(t) err := service.OpenWindow( - WindowName("custom-window"), - WindowTitle("Custom Title"), - WindowWidth(640), - WindowHeight(480), - WindowURL("/custom"), + window.WithName("custom-window"), + window.WithTitle("Custom Title"), + window.WithSize(640, 480), + window.WithURL("/custom"), ) assert.NoError(t, err) - assert.Len(t, mock.windowManager.createdWindows, 1) - opts := mock.windowManager.createdWindows[0] - assert.Equal(t, "custom-window", opts.Name) - assert.Equal(t, "Custom Title", opts.Title) - assert.Equal(t, 640, opts.Width) - assert.Equal(t, 480, opts.Height) - assert.Equal(t, "/custom", opts.URL) + assert.Len(t, wp.windows, 1) + assert.Equal(t, "custom-window", wp.windows[0].name) + assert.Equal(t, "Custom Title", wp.windows[0].title) + assert.Equal(t, 640, wp.windows[0].width) + assert.Equal(t, 480, wp.windows[0].height) }) } -func TestNewWithStruct(t *testing.T) { - t.Run("creates window from struct", func(t *testing.T) { - service, mock := newServiceWithMockApp(t) +func TestGetWindowInfo_Good(t *testing.T) { + service, _, wp := newServiceWithMocks(t) - opts := &Window{ - Name: "struct-window", - Title: "Struct Title", + _ = service.OpenWindow( + window.WithName("test-win"), + window.WithSize(800, 600), + ) + + // Set position on the mock window + wp.windows[0].x = 100 + wp.windows[0].y = 200 + + info, err := service.GetWindowInfo("test-win") + require.NoError(t, err) + assert.Equal(t, "test-win", info.Name) + assert.Equal(t, 100, info.X) + assert.Equal(t, 200, info.Y) + assert.Equal(t, 800, info.Width) + assert.Equal(t, 600, info.Height) +} + +func TestGetWindowInfo_Bad(t *testing.T) { + service, _, _ := newServiceWithMocks(t) + + _, err := service.GetWindowInfo("nonexistent") + assert.Error(t, err) + assert.Contains(t, err.Error(), "window not found") +} + +func TestListWindowInfos_Good(t *testing.T) { + service, _, _ := newServiceWithMocks(t) + + _ = service.OpenWindow(window.WithName("win-1")) + _ = service.OpenWindow(window.WithName("win-2")) + + infos := service.ListWindowInfos() + assert.Len(t, infos, 2) +} + +func TestSetWindowPosition_Good(t *testing.T) { + service, _, wp := newServiceWithMocks(t) + _ = service.OpenWindow(window.WithName("pos-win")) + + err := service.SetWindowPosition("pos-win", 300, 400) + assert.NoError(t, err) + assert.Equal(t, 300, wp.windows[0].x) + assert.Equal(t, 400, wp.windows[0].y) +} + +func TestSetWindowPosition_Bad(t *testing.T) { + service, _, _ := newServiceWithMocks(t) + + err := service.SetWindowPosition("nonexistent", 0, 0) + assert.Error(t, err) +} + +func TestSetWindowSize_Good(t *testing.T) { + service, _, wp := newServiceWithMocks(t) + _ = service.OpenWindow(window.WithName("size-win")) + + err := service.SetWindowSize("size-win", 1024, 768) + assert.NoError(t, err) + assert.Equal(t, 1024, wp.windows[0].width) + assert.Equal(t, 768, wp.windows[0].height) +} + +func TestMaximizeWindow_Good(t *testing.T) { + service, _, wp := newServiceWithMocks(t) + _ = service.OpenWindow(window.WithName("max-win")) + + err := service.MaximizeWindow("max-win") + assert.NoError(t, err) + assert.True(t, wp.windows[0].maximised) +} + +func TestRestoreWindow_Good(t *testing.T) { + service, _, wp := newServiceWithMocks(t) + _ = service.OpenWindow(window.WithName("restore-win")) + wp.windows[0].maximised = true + + err := service.RestoreWindow("restore-win") + assert.NoError(t, err) + assert.False(t, wp.windows[0].maximised) +} + +func TestFocusWindow_Good(t *testing.T) { + service, _, wp := newServiceWithMocks(t) + _ = service.OpenWindow(window.WithName("focus-win")) + + err := service.FocusWindow("focus-win") + assert.NoError(t, err) + assert.True(t, wp.windows[0].focused) +} + +func TestCloseWindow_Good(t *testing.T) { + service, _, wp := newServiceWithMocks(t) + _ = service.OpenWindow(window.WithName("close-win")) + + err := service.CloseWindow("close-win") + assert.NoError(t, err) + assert.True(t, wp.windows[0].closed) + + // Window should be removed from manager + _, ok := service.windows.Get("close-win") + assert.False(t, ok) +} + +func TestSetWindowVisibility_Good(t *testing.T) { + service, _, wp := newServiceWithMocks(t) + _ = service.OpenWindow(window.WithName("vis-win")) + + err := service.SetWindowVisibility("vis-win", false) + assert.NoError(t, err) + assert.False(t, wp.windows[0].visible) + + err = service.SetWindowVisibility("vis-win", true) + assert.NoError(t, err) + assert.True(t, wp.windows[0].visible) +} + +func TestSetWindowAlwaysOnTop_Good(t *testing.T) { + service, _, wp := newServiceWithMocks(t) + _ = service.OpenWindow(window.WithName("ontop-win")) + + err := service.SetWindowAlwaysOnTop("ontop-win", true) + assert.NoError(t, err) + assert.True(t, wp.windows[0].alwaysOnTop) +} + +func TestSetWindowTitle_Good(t *testing.T) { + service, _, wp := newServiceWithMocks(t) + _ = service.OpenWindow(window.WithName("title-win")) + + err := service.SetWindowTitle("title-win", "New Title") + assert.NoError(t, err) + assert.Equal(t, "New Title", wp.windows[0].title) +} + +func TestGetFocusedWindow_Good(t *testing.T) { + service, _, wp := newServiceWithMocks(t) + _ = service.OpenWindow(window.WithName("win-a")) + _ = service.OpenWindow(window.WithName("win-b")) + wp.windows[1].focused = true + + focused := service.GetFocusedWindow() + assert.Equal(t, "win-b", focused) +} + +func TestGetFocusedWindow_NoneSelected(t *testing.T) { + service, _, _ := newServiceWithMocks(t) + _ = service.OpenWindow(window.WithName("win-a")) + + focused := service.GetFocusedWindow() + assert.Equal(t, "", focused) +} + +func TestCreateWindow_Good(t *testing.T) { + service, _, wp := newServiceWithMocks(t) + + info, err := service.CreateWindow(CreateWindowOptions{ + Name: "new-win", + Title: "New Window", + URL: "/new", + Width: 600, + Height: 400, + }) + require.NoError(t, err) + assert.Equal(t, "new-win", info.Name) + assert.Equal(t, 600, info.Width) + assert.Equal(t, 400, info.Height) + assert.Len(t, wp.windows, 1) +} + +func TestCreateWindow_Bad(t *testing.T) { + service, _, _ := newServiceWithMocks(t) + + _, err := service.CreateWindow(CreateWindowOptions{}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "window name is required") +} + +func TestShowEnvironmentDialog_Good(t *testing.T) { + service, mock, _ := newServiceWithMocks(t) + + // This will panic because Dialog().Info() returns nil + // We're verifying the env info is accessed, not that a dialog shows + assert.NotPanics(t, func() { + defer func() { recover() }() // Recover from nil dialog + service.ShowEnvironmentDialog() + }) + + // Verify dialog was requested (even though it's nil) + assert.Equal(t, 1, mock.dialogManager.infoDialogsCreated) +} + +func TestBuildMenu_Good(t *testing.T) { + service, _, _ := newServiceWithMocks(t) + coreInstance := newTestCore(t) + service.ServiceRuntime = core.NewServiceRuntime[Options](coreInstance, Options{}) + + // buildMenu should not panic with mock platforms + assert.NotPanics(t, func() { + service.buildMenu() + }) +} + +func TestSetupTray_Good(t *testing.T) { + service, _, _ := newServiceWithMocks(t) + coreInstance := newTestCore(t) + service.ServiceRuntime = core.NewServiceRuntime[Options](coreInstance, Options{}) + + // setupTray should not panic with mock platforms + assert.NotPanics(t, func() { + service.setupTray() + }) + + // Verify tray is active + assert.True(t, service.tray.IsActive()) +} + +func TestHandleNewWorkspace_Good(t *testing.T) { + service, _, wp := newServiceWithMocks(t) + + service.handleNewWorkspace() + + // Verify a window was created with correct options + assert.Len(t, wp.windows, 1) + assert.Equal(t, "workspace-new", wp.windows[0].name) + assert.Equal(t, "New Workspace", wp.windows[0].title) + assert.Equal(t, 500, wp.windows[0].width) + assert.Equal(t, 400, wp.windows[0].height) +} + +func TestHandleListWorkspaces_Good(t *testing.T) { + service, _, _ := newServiceWithMocks(t) + coreInstance := newTestCore(t) + service.ServiceRuntime = core.NewServiceRuntime[Options](coreInstance, Options{}) + + // handleListWorkspaces should not panic when workspace service is not available + assert.NotPanics(t, func() { + service.handleListWorkspaces() + }) +} + +func TestHandleSaveFile_Good(t *testing.T) { + service, mock, _ := newServiceWithMocks(t) + + service.handleSaveFile() + + assert.Contains(t, mock.eventManager.emittedEvents, "ide:save") +} + +func TestHandleRun_Good(t *testing.T) { + service, mock, _ := newServiceWithMocks(t) + + service.handleRun() + + assert.Contains(t, mock.eventManager.emittedEvents, "ide:run") +} + +func TestHandleBuild_Good(t *testing.T) { + service, mock, _ := newServiceWithMocks(t) + + service.handleBuild() + + assert.Contains(t, mock.eventManager.emittedEvents, "ide:build") +} + +func TestWSEventManager_Good(t *testing.T) { + es := newMockEventSource() + em := NewWSEventManager(es) + defer em.Close() + + assert.NotNil(t, em) + assert.Equal(t, 0, em.ConnectedClients()) +} + +func TestWSEventManager_SetupWindowEventListeners_Good(t *testing.T) { + es := newMockEventSource() + em := NewWSEventManager(es) + defer em.Close() + + em.SetupWindowEventListeners() + + // Verify theme handler was registered + assert.Len(t, es.themeHandlers, 1) +} + +func TestResetWindowState_Good(t *testing.T) { + service, _, _ := newServiceWithMocks(t) + + err := service.ResetWindowState() + assert.NoError(t, err) +} + +func TestGetSavedWindowStates_Good(t *testing.T) { + service, _, _ := newServiceWithMocks(t) + + states := service.GetSavedWindowStates() + assert.NotNil(t, states) +} + +func TestActionOpenWindow_Good(t *testing.T) { + action := ActionOpenWindow{ + Window: window.Window{ + Name: "test", + Title: "Test Window", Width: 800, Height: 600, - URL: "/struct", - } + }, + } - _, err := service.NewWithStruct(opts) - assert.NoError(t, err) - - assert.Len(t, mock.windowManager.createdWindows, 1) - created := mock.windowManager.createdWindows[0] - assert.Equal(t, "struct-window", created.Name) - assert.Equal(t, "Struct Title", created.Title) - assert.Equal(t, 800, created.Width) - assert.Equal(t, 600, created.Height) - }) -} - -func TestNewWithOptions(t *testing.T) { - t.Run("creates window from options", func(t *testing.T) { - service, mock := newServiceWithMockApp(t) - - _, err := service.NewWithOptions( - WindowName("options-window"), - WindowTitle("Options Title"), - ) - assert.NoError(t, err) - - assert.Len(t, mock.windowManager.createdWindows, 1) - opts := mock.windowManager.createdWindows[0] - assert.Equal(t, "options-window", opts.Name) - assert.Equal(t, "Options Title", opts.Title) - }) -} - -func TestNewWithURL(t *testing.T) { - t.Run("creates window with URL", func(t *testing.T) { - service, mock := newServiceWithMockApp(t) - - _, err := service.NewWithURL("/dashboard") - assert.NoError(t, err) - - assert.Len(t, mock.windowManager.createdWindows, 1) - opts := mock.windowManager.createdWindows[0] - assert.Equal(t, "/dashboard", opts.URL) - assert.Equal(t, "Core", opts.Title) - assert.Equal(t, 1280, opts.Width) - assert.Equal(t, 900, opts.Height) - }) -} - -func TestHandleOpenWindowAction(t *testing.T) { - t.Run("creates window from message map", func(t *testing.T) { - service, mock := newServiceWithMockApp(t) - - msg := map[string]any{ - "name": "action-window", - "options": map[string]any{ - "Title": "Action Title", - "Width": float64(1024), - "Height": float64(768), - }, - } - - err := service.handleOpenWindowAction(msg) - assert.NoError(t, err) - - assert.Len(t, mock.windowManager.createdWindows, 1) - opts := mock.windowManager.createdWindows[0] - assert.Equal(t, "action-window", opts.Name) - assert.Equal(t, "Action Title", opts.Title) - assert.Equal(t, 1024, opts.Width) - assert.Equal(t, 768, opts.Height) - }) -} - -func TestMonitorScreenChanges(t *testing.T) { - t.Run("registers theme change event", func(t *testing.T) { - service, mock := newServiceWithMockApp(t) - - service.monitorScreenChanges() - - // Verify that an event handler was registered - assert.Len(t, mock.eventManager.registeredEvents, 1) - }) -} - -func TestSelectDirectory(t *testing.T) { - t.Run("requires Wails runtime for file dialog", func(t *testing.T) { - // SelectDirectory uses application.OpenFileDialog() directly - // which requires Wails runtime. This test verifies the method exists. - service, _ := newServiceWithMockApp(t) - assert.NotNil(t, service.SelectDirectory) - }) -} - -func TestShowEnvironmentDialog(t *testing.T) { - t.Run("calls dialog with environment info", func(t *testing.T) { - service, mock := newServiceWithMockApp(t) - - // This will panic because Dialog().Info() returns nil - // We're verifying the env info is accessed, not that a dialog shows - assert.NotPanics(t, func() { - defer func() { recover() }() // Recover from nil dialog - service.ShowEnvironmentDialog() - }) - - // Verify dialog was requested (even though it's nil) - assert.Equal(t, 1, mock.dialogManager.infoDialogsCreated) - }) -} - -func TestBuildMenu(t *testing.T) { - t.Run("creates and sets menu", func(t *testing.T) { - service, mock := newServiceWithMockApp(t) - coreInstance := newTestCore(t) - service.ServiceRuntime = core.NewServiceRuntime[Options](coreInstance, Options{}) - - // buildMenu will panic because Menu().New() returns nil - // We verify the menu manager was called - assert.NotPanics(t, func() { - defer func() { recover() }() - service.buildMenu() - }) - - assert.Equal(t, 1, mock.menuManager.menusCreated) - }) -} - -func TestSystemTray(t *testing.T) { - t.Run("creates system tray", func(t *testing.T) { - service, mock := newServiceWithMockApp(t) - coreInstance := newTestCore(t) - service.ServiceRuntime = core.NewServiceRuntime[Options](coreInstance, Options{}) - - // systemTray will panic because SystemTray().New() returns nil - // We verify the system tray manager was called - assert.NotPanics(t, func() { - defer func() { recover() }() - service.systemTray() - }) - - assert.Equal(t, 1, mock.systemTrayMgr.traysCreated) - }) -} - -func TestApplyOptionsWithError(t *testing.T) { - t.Run("returns nil when option returns error", func(t *testing.T) { - errorOption := func(o *Window) error { - return assert.AnError - } - - result := applyOptions(errorOption) - assert.Nil(t, result) - }) - - t.Run("processes multiple options until error", func(t *testing.T) { - firstOption := func(o *Window) error { - o.Name = "first" - return nil - } - errorOption := func(o *Window) error { - return assert.AnError - } - - result := applyOptions(firstOption, errorOption) - assert.Nil(t, result) - // The first option should have run before error - // But the result is nil so we can't check - }) - - t.Run("handles empty options slice", func(t *testing.T) { - opts := []WindowOption{} - result := applyOptions(opts...) - assert.NotNil(t, result) - assert.Equal(t, "", result.Name) // Default empty values - }) -} - -func TestHandleNewWorkspace(t *testing.T) { - t.Run("opens workspace creation window", func(t *testing.T) { - service, mock := newServiceWithMockApp(t) - - service.handleNewWorkspace() - - // Verify a window was created with correct options - assert.Len(t, mock.windowManager.createdWindows, 1) - opts := mock.windowManager.createdWindows[0] - assert.Equal(t, "workspace-new", opts.Name) - assert.Equal(t, "New Workspace", opts.Title) - assert.Equal(t, 500, opts.Width) - assert.Equal(t, 400, opts.Height) - assert.Equal(t, "/workspace/new", opts.URL) - }) -} - -func TestHandleListWorkspaces(t *testing.T) { - t.Run("shows warning when workspace service not available", func(t *testing.T) { - service, mock := newServiceWithMockApp(t) - coreInstance := newTestCore(t) - service.ServiceRuntime = core.NewServiceRuntime[Options](coreInstance, Options{}) - - // Don't register workspace service - it won't be available - // This will panic because Dialog().Warning() returns nil - assert.NotPanics(t, func() { - defer func() { recover() }() - service.handleListWorkspaces() - }) - - assert.Equal(t, 1, mock.dialogManager.warningDialogsCreated) - }) -} - -func TestParseWindowOptions(t *testing.T) { - t.Run("parses complete options", func(t *testing.T) { - msg := map[string]any{ - "name": "test-window", - "options": map[string]any{ - "Title": "Test Title", - "Width": float64(800), - "Height": float64(600), - }, - } - - opts := parseWindowOptions(msg) - - assert.Equal(t, "test-window", opts.Name) - assert.Equal(t, "Test Title", opts.Title) - assert.Equal(t, 800, opts.Width) - assert.Equal(t, 600, opts.Height) - }) - - t.Run("handles missing name", func(t *testing.T) { - msg := map[string]any{ - "options": map[string]any{ - "Title": "Test Title", - }, - } - - opts := parseWindowOptions(msg) - - assert.Equal(t, "", opts.Name) - assert.Equal(t, "Test Title", opts.Title) - }) - - t.Run("handles missing options", func(t *testing.T) { - msg := map[string]any{ - "name": "test-window", - } - - opts := parseWindowOptions(msg) - - assert.Equal(t, "test-window", opts.Name) - assert.Equal(t, "", opts.Title) - assert.Equal(t, 0, opts.Width) - assert.Equal(t, 0, opts.Height) - }) - - t.Run("handles empty map", func(t *testing.T) { - msg := map[string]any{} - - opts := parseWindowOptions(msg) - - assert.Equal(t, "", opts.Name) - assert.Equal(t, "", opts.Title) - }) - - t.Run("handles wrong type for name", func(t *testing.T) { - msg := map[string]any{ - "name": 123, // Wrong type - should be string - } - - opts := parseWindowOptions(msg) - - assert.Equal(t, "", opts.Name) // Should not set name - }) - - t.Run("handles wrong type for options", func(t *testing.T) { - msg := map[string]any{ - "name": "test", - "options": "not-a-map", // Wrong type - } - - opts := parseWindowOptions(msg) - - assert.Equal(t, "test", opts.Name) - assert.Equal(t, "", opts.Title) // Options not parsed - }) - - t.Run("handles partial width/height", func(t *testing.T) { - msg := map[string]any{ - "options": map[string]any{ - "Width": float64(800), - // Height missing - }, - } - - opts := parseWindowOptions(msg) - - assert.Equal(t, 800, opts.Width) - assert.Equal(t, 0, opts.Height) - }) -} - -func TestBuildWailsWindowOptions(t *testing.T) { - t.Run("creates default options with no args", func(t *testing.T) { - opts := buildWailsWindowOptions() - - assert.Equal(t, "main", opts.Name) - assert.Equal(t, "Core", opts.Title) - assert.Equal(t, 1280, opts.Width) - assert.Equal(t, 800, opts.Height) - assert.Equal(t, "/", opts.URL) - }) - - t.Run("applies custom options", func(t *testing.T) { - opts := buildWailsWindowOptions( - WindowName("custom"), - WindowTitle("Custom Title"), - WindowWidth(640), - WindowHeight(480), - WindowURL("/custom"), - ) - - assert.Equal(t, "custom", opts.Name) - assert.Equal(t, "Custom Title", opts.Title) - assert.Equal(t, 640, opts.Width) - assert.Equal(t, 480, opts.Height) - assert.Equal(t, "/custom", opts.URL) - }) - - t.Run("skips nil options", func(t *testing.T) { - opts := buildWailsWindowOptions(nil, WindowTitle("Test")) - - assert.Equal(t, "Test", opts.Title) - assert.Equal(t, "main", opts.Name) // Default preserved - }) + assert.Equal(t, "test", action.Name) + assert.Equal(t, "Test Window", action.Title) + assert.Equal(t, 800, action.Width) + assert.Equal(t, 600, action.Height) } diff --git a/pkg/display/events.go b/pkg/display/events.go index 4d0bdb7..ed10346 100644 --- a/pkg/display/events.go +++ b/pkg/display/events.go @@ -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) }) } diff --git a/pkg/display/interfaces.go b/pkg/display/interfaces.go index 0065601..c1a8b3a 100644 --- a/pkg/display/interfaces.go +++ b/pkg/display/interfaces.go @@ -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...) } diff --git a/pkg/display/layout.go b/pkg/display/layout.go deleted file mode 100644 index 1e904e4..0000000 --- a/pkg/display/layout.go +++ /dev/null @@ -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"` -} diff --git a/pkg/display/menu.go b/pkg/display/menu.go deleted file mode 100644 index f4a6b37..0000000 --- a/pkg/display/menu.go +++ /dev/null @@ -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") -} diff --git a/pkg/display/mocks_test.go b/pkg/display/mocks_test.go index 37ff647..b619977 100644 --- a/pkg/display/mocks_test.go +++ b/pkg/display/mocks_test.go @@ -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 +} diff --git a/pkg/display/tray.go b/pkg/display/tray.go deleted file mode 100644 index 3f39d15..0000000 --- a/pkg/display/tray.go +++ /dev/null @@ -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, - } -} diff --git a/pkg/display/window.go b/pkg/display/window.go deleted file mode 100644 index 5b05ee2..0000000 --- a/pkg/display/window.go +++ /dev/null @@ -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() -//} diff --git a/pkg/display/window_state.go b/pkg/display/window_state.go deleted file mode 100644 index 9c1888f..0000000 --- a/pkg/display/window_state.go +++ /dev/null @@ -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 -} diff --git a/pkg/window/layout.go b/pkg/window/layout.go index 578af63..545a99f 100644 --- a/pkg/window/layout.go +++ b/pkg/window/layout.go @@ -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") } diff --git a/pkg/window/state.go b/pkg/window/state.go index f6784aa..3523cfe 100644 --- a/pkg/window/state.go +++ b/pkg/window/state.go @@ -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") } diff --git a/pkg/window/window.go b/pkg/window/window.go index 7c2c0d5..3692fe8 100644 --- a/pkg/window/window.go +++ b/pkg/window/window.go @@ -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...)