From 4814f960fb36faac76d734270030763bb104c372 Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 13 Mar 2026 12:27:19 +0000 Subject: [PATCH] refactor(display): compose window/systray/menu sub-packages into orchestrator Service now delegates to window.Manager, systray.Manager, and menu.Manager instead of directly using Wails types. WSEventManager accepts EventSource interface instead of calling application.Get() directly. AttachWindowListeners now accepts window.PlatformWindow. Removes migrated files: window.go, window_state.go, layout.go, tray.go, menu.go. Tests rewritten against mock platform implementations. Co-Authored-By: Claude Opus 4.6 --- go.mod | 52 +- go.sum | 101 +-- pkg/display/actions.go | 19 +- pkg/display/display.go | 1618 ++++++++++++----------------------- pkg/display/display_test.go | 1061 +++++++++++------------ pkg/display/events.go | 82 +- pkg/display/interfaces.go | 103 +-- pkg/display/layout.go | 149 ---- pkg/display/menu.go | 185 ---- pkg/display/mocks_test.go | 96 +-- pkg/display/tray.go | 200 ----- pkg/display/window.go | 90 -- pkg/display/window_state.go | 261 ------ pkg/window/layout.go | 11 + pkg/window/state.go | 11 + pkg/window/window.go | 11 + 16 files changed, 1194 insertions(+), 2856 deletions(-) delete mode 100644 pkg/display/layout.go delete mode 100644 pkg/display/menu.go delete mode 100644 pkg/display/tray.go delete mode 100644 pkg/display/window.go delete mode 100644 pkg/display/window_state.go 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...)