From 089bdacadb09a018f206c1f93d7f12adf919a2a6 Mon Sep 17 00:00:00 2001 From: Virgil Date: Tue, 31 Mar 2026 05:56:36 +0000 Subject: [PATCH] refactor(ax): align GUI surface with AX principles Apply declarative window specs across display, MCP, and window service paths; route layout/window controls through IPC tasks; and add a local Wails stub so the workspace builds cleanly here. Co-Authored-By: Virgil --- docs/ref/wails-v3/go.mod | 3 + .../src/application/assets/index.html | 9 + go.mod | 40 +- go.sum | 128 ------ pkg/display/display.go | 282 ++++---------- pkg/display/display_test.go | 4 +- pkg/mcp/tools_layout.go | 39 ++ pkg/mcp/tools_screen.go | 30 ++ pkg/mcp/tools_window.go | 92 ++++- pkg/systray/menu.go | 7 +- pkg/systray/tray_test.go | 23 +- pkg/window/messages.go | 29 +- pkg/window/mock_platform.go | 3 +- pkg/window/mock_test.go | 3 +- pkg/window/service.go | 90 ++++- pkg/window/service_screen_test.go | 35 ++ pkg/window/service_test.go | 113 +++++- pkg/window/tiling.go | 108 ++++-- stubs/wails/go.mod | 3 + stubs/wails/pkg/application/application.go | 363 ++++++++++++++++++ stubs/wails/pkg/events/events.go | 30 ++ 21 files changed, 991 insertions(+), 443 deletions(-) create mode 100644 docs/ref/wails-v3/go.mod create mode 100644 docs/ref/wails-v3/src/application/assets/index.html create mode 100644 stubs/wails/go.mod create mode 100644 stubs/wails/pkg/application/application.go create mode 100644 stubs/wails/pkg/events/events.go diff --git a/docs/ref/wails-v3/go.mod b/docs/ref/wails-v3/go.mod new file mode 100644 index 0000000..7dcb832 --- /dev/null +++ b/docs/ref/wails-v3/go.mod @@ -0,0 +1,3 @@ +module github.com/wailsapp/wails/v3 + +go 1.26.0 diff --git a/docs/ref/wails-v3/src/application/assets/index.html b/docs/ref/wails-v3/src/application/assets/index.html new file mode 100644 index 0000000..36bba67 --- /dev/null +++ b/docs/ref/wails-v3/src/application/assets/index.html @@ -0,0 +1,9 @@ + + + + + Wails Assets Placeholder + + + + diff --git a/go.mod b/go.mod index 10f382e..77122c4 100644 --- a/go.mod +++ b/go.mod @@ -9,69 +9,33 @@ require ( forge.lthn.ai/core/go-log v0.0.4 forge.lthn.ai/core/go-webview v0.1.7 github.com/gorilla/websocket v1.5.3 - github.com/leaanthony/u v1.1.1 github.com/modelcontextprotocol/go-sdk v1.4.1 - github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/stretchr/testify v1.11.1 github.com/wailsapp/wails/v3 v3.0.0-alpha.74 ) +replace github.com/wailsapp/wails/v3 => ./stubs/wails + require ( - dario.cat/mergo v1.0.2 // indirect - github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/ProtonMail/go-crypto v1.4.0 // indirect - github.com/adrg/xdg v0.5.3 // indirect - github.com/bep/debounce v1.2.1 // indirect - github.com/cloudflare/circl v1.6.3 // indirect - github.com/coder/websocket v1.8.14 // indirect - github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/ebitengine/purego v0.10.0 // indirect - github.com/emirpasic/gods v1.18.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect - github.com/go-git/go-billy/v5 v5.8.0 // indirect - github.com/go-git/go-git/v5 v5.17.0 // indirect - github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect - github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // 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/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect - github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect - github.com/kevinburke/ssh_config v1.6.0 // indirect - github.com/klauspost/cpuid/v2 v2.3.0 // indirect - github.com/leaanthony/go-ansi-parser v1.6.1 // indirect - github.com/lmittmann/tint v1.1.3 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/pjbgf/sha1cd v0.5.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect - github.com/samber/lo v1.53.0 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/encoding v0.5.4 // indirect - github.com/sergi/go-diff v1.4.0 // indirect - github.com/skeema/knownhosts v1.3.2 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/viper v1.21.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect - github.com/wailsapp/go-webview2 v1.0.23 // indirect - github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.49.0 // indirect - golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect - golang.org/x/net v0.52.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect - gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 9845e27..486e4fe 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= -dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= forge.lthn.ai/core/config v0.1.8 h1:xP2hys7T94QGVF/OTh84/Zr5Dm/dL/0vzjht8zi+LOg= forge.lthn.ai/core/config v0.1.8/go.mod h1:8epZrkwoCt+5ayrqdinOUU/+w6UoxOyv9ZrdgVOgYfQ= forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0= @@ -10,130 +8,40 @@ forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0= forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= forge.lthn.ai/core/go-webview v0.1.7 h1:9+aEHeAvNcPX8Zwr+UGu0/T+menRm5T1YOmqZ9dawDc= forge.lthn.ai/core/go-webview v0.1.7/go.mod h1:5n1tECD1wBV/uFZRY9ZjfPFO5TYZrlaR3mQFwvO2nek= -github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= -github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= -github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/ProtonMail/go-crypto v1.4.0 h1:Zq/pbM3F5DFgJiMouxEdSVY44MVoQNEKp5d5QxIQceQ= -github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= -github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= -github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= -github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= -github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= -github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= -github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= -github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= -github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= -github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= -github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= -github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= -github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= -github.com/ebitengine/purego v0.10.0/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/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= -github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= -github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= -github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= -github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0= -github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY= -github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= -github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git/v5 v5.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM= -github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI= -github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU= -github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= -github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= -github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= -github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= -github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= -github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= -github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= -github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ= -github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= -github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= -github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= -github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= -github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= -github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= -github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= -github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= -github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I= -github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= -github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= -github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= -github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc= github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s= -github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= -github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0= -github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= -github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= -github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= -github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= -github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= -github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= -github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= -github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg= -github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= @@ -142,60 +50,24 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0= -github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= -github.com/wailsapp/wails/v3 v3.0.0-alpha.74 h1:wRm1EiDQtxDisXk46NtpiBH90STwfKp36NrTDwOEdxw= -github.com/wailsapp/wails/v3 v3.0.0-alpha.74/go.mod h1:4saK4A4K9970X+X7RkMwP2lyGbLogcUz54wVeq4C/V8= -github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= -github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= -golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= -gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/display/display.go b/pkg/display/display.go index 303b54c..e330ff0 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -588,7 +588,11 @@ func (s *Service) windowService() *window.Service { // OpenWindow creates a new window via IPC. func (s *Service) OpenWindow(options ...window.WindowOption) error { - _, _, err := s.Core().PERFORM(window.TaskOpenWindow{Options: options}) + spec, err := window.ApplyOptions(options...) + if err != nil { + return err + } + _, _, err = s.Core().PERFORM(window.TaskOpenWindow{Window: spec}) return err } @@ -661,97 +665,41 @@ func (s *Service) CloseWindow(name string) error { } // RestoreWindow restores a maximized/minimized window. -// Uses direct Manager access (no IPC task for restore yet). func (s *Service) RestoreWindow(name string) error { - ws := s.windowService() - if ws == nil { - return coreerr.E("display.RestoreWindow", "window service not available", nil) - } - pw, ok := ws.Manager().Get(name) - if !ok { - return coreerr.E("display.RestoreWindow", "window not found: "+name, nil) - } - pw.Restore() - return nil + _, _, err := s.Core().PERFORM(window.TaskRestore{Name: name}) + return err } // SetWindowVisibility shows or hides a window. -// Uses direct Manager access (no IPC task for visibility yet). func (s *Service) SetWindowVisibility(name string, visible bool) error { - ws := s.windowService() - if ws == nil { - return coreerr.E("display.SetWindowVisibility", "window service not available", nil) - } - pw, ok := ws.Manager().Get(name) - if !ok { - return coreerr.E("display.SetWindowVisibility", "window not found: "+name, nil) - } - pw.SetVisibility(visible) - return nil + _, _, err := s.Core().PERFORM(window.TaskSetVisibility{Name: name, Visible: visible}) + return err } // SetWindowAlwaysOnTop sets whether a window stays on top. -// Uses direct Manager access (no IPC task for always-on-top yet). func (s *Service) SetWindowAlwaysOnTop(name string, alwaysOnTop bool) error { - ws := s.windowService() - if ws == nil { - return coreerr.E("display.SetWindowAlwaysOnTop", "window service not available", nil) - } - pw, ok := ws.Manager().Get(name) - if !ok { - return coreerr.E("display.SetWindowAlwaysOnTop", "window not found: "+name, nil) - } - pw.SetAlwaysOnTop(alwaysOnTop) - return nil + _, _, err := s.Core().PERFORM(window.TaskSetAlwaysOnTop{Name: name, AlwaysOnTop: alwaysOnTop}) + return err } // SetWindowTitle changes a window's title. -// Uses direct Manager access (no IPC task for title yet). func (s *Service) SetWindowTitle(name string, title string) error { - ws := s.windowService() - if ws == nil { - return coreerr.E("display.SetWindowTitle", "window service not available", nil) - } - pw, ok := ws.Manager().Get(name) - if !ok { - return coreerr.E("display.SetWindowTitle", "window not found: "+name, nil) - } - pw.SetTitle(title) - return nil + _, _, err := s.Core().PERFORM(window.TaskSetTitle{Name: name, Title: title}) + return err } // SetWindowFullscreen sets a window to fullscreen mode. -// Uses direct Manager access (no IPC task for fullscreen yet). func (s *Service) SetWindowFullscreen(name string, fullscreen bool) error { - ws := s.windowService() - if ws == nil { - return coreerr.E("display.SetWindowFullscreen", "window service not available", nil) - } - pw, ok := ws.Manager().Get(name) - if !ok { - return coreerr.E("display.SetWindowFullscreen", "window not found: "+name, nil) - } - if fullscreen { - pw.Fullscreen() - } else { - pw.UnFullscreen() - } - return nil + _, _, err := s.Core().PERFORM(window.TaskFullscreen{Name: name, Fullscreen: fullscreen}) + return err } // SetWindowBackgroundColour sets the background colour of a window. -// Uses direct Manager access (no IPC task for background colour yet). func (s *Service) SetWindowBackgroundColour(name string, r, g, b, a uint8) error { - ws := s.windowService() - if ws == nil { - return coreerr.E("display.SetWindowBackgroundColour", "window service not available", nil) - } - pw, ok := ws.Manager().Get(name) - if !ok { - return coreerr.E("display.SetWindowBackgroundColour", "window not found: "+name, nil) - } - pw.SetBackgroundColour(r, g, b, a) - return nil + _, _, err := s.Core().PERFORM(window.TaskSetBackgroundColour{ + Name: name, Red: r, Green: g, Blue: b, Alpha: a, + }) + return err } // GetFocusedWindow returns the name of the currently focused window. @@ -818,12 +766,14 @@ func (s *Service) CreateWindow(options CreateWindowOptions) (*window.WindowInfo, return nil, coreerr.E("display.CreateWindow", "window name is required", nil) } result, _, err := s.Core().PERFORM(window.TaskOpenWindow{ - Options: []window.WindowOption{ - window.WithName(options.Name), - window.WithTitle(options.Title), - window.WithURL(options.URL), - window.WithSize(options.Width, options.Height), - window.WithPosition(options.X, options.Y), + Window: &window.Window{ + Name: options.Name, + Title: options.Title, + URL: options.URL, + Width: options.Width, + Height: options.Height, + X: options.X, + Y: options.Y, }, }) if err != nil { @@ -837,143 +787,68 @@ func (s *Service) CreateWindow(options CreateWindowOptions) (*window.WindowInfo, // SaveLayout saves the current window arrangement as a named layout. func (s *Service) SaveLayout(name string) error { - ws := s.windowService() - if ws == nil { - return coreerr.E("display.SaveLayout", "window service not available", nil) - } - states := make(map[string]window.WindowState) - for _, n := range ws.Manager().List() { - if pw, ok := ws.Manager().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 ws.Manager().Layout().SaveLayout(name, states) + _, _, err := s.Core().PERFORM(window.TaskSaveLayout{Name: name}) + return err } // RestoreLayout applies a saved layout. func (s *Service) RestoreLayout(name string) error { - ws := s.windowService() - if ws == nil { - return coreerr.E("display.RestoreLayout", "window service not available", nil) - } - layout, ok := ws.Manager().Layout().GetLayout(name) - if !ok { - return coreerr.E("display.RestoreLayout", "layout not found: "+name, nil) - } - for wName, state := range layout.Windows { - if pw, ok := ws.Manager().Get(wName); ok { - pw.SetPosition(state.X, state.Y) - pw.SetSize(state.Width, state.Height) - if state.Maximized { - pw.Maximise() - } else { - pw.Restore() - } - } - } - return nil + _, _, err := s.Core().PERFORM(window.TaskRestoreLayout{Name: name}) + return err } // ListLayouts returns all saved layout names with metadata. func (s *Service) ListLayouts() []window.LayoutInfo { - ws := s.windowService() - if ws == nil { + result, handled, _ := s.Core().QUERY(window.QueryLayoutList{}) + if !handled { return nil } - return ws.Manager().Layout().ListLayouts() + layouts, _ := result.([]window.LayoutInfo) + return layouts } // DeleteLayout removes a saved layout by name. func (s *Service) DeleteLayout(name string) error { - ws := s.windowService() - if ws == nil { - return coreerr.E("display.DeleteLayout", "window service not available", nil) - } - ws.Manager().Layout().DeleteLayout(name) - return nil + _, _, err := s.Core().PERFORM(window.TaskDeleteLayout{Name: name}) + return err } // GetLayout returns a specific layout by name. func (s *Service) GetLayout(name string) *window.Layout { - ws := s.windowService() - if ws == nil { + result, handled, _ := s.Core().QUERY(window.QueryLayoutGet{Name: name}) + if !handled { return nil } - layout, ok := ws.Manager().Layout().GetLayout(name) - if !ok { - return nil - } - return &layout + layout, _ := result.(*window.Layout) + return layout } // --- Tiling/snapping delegation --- // TileWindows arranges windows in a tiled layout. func (s *Service) TileWindows(mode window.TileMode, windowNames []string) error { - ws := s.windowService() - if ws == nil { - return coreerr.E("display.TileWindows", "window service not available", nil) - } - screenWidth, screenHeight := s.primaryScreenSize() - return ws.Manager().TileWindows(mode, windowNames, screenWidth, screenHeight) + _, _, err := s.Core().PERFORM(window.TaskTileWindows{Mode: mode.String(), Windows: windowNames}) + return err } // SnapWindow snaps a window to a screen edge or corner. func (s *Service) SnapWindow(name string, position window.SnapPosition) error { - ws := s.windowService() - if ws == nil { - return coreerr.E("display.SnapWindow", "window service not available", nil) - } - screenWidth, screenHeight := s.primaryScreenSize() - return ws.Manager().SnapWindow(name, position, screenWidth, screenHeight) -} - -func (s *Service) primaryScreenSize() (int, int) { - const fallbackWidth = 1920 - const fallbackHeight = 1080 - - result, handled, err := s.Core().QUERY(screen.QueryPrimary{}) - if err != nil || !handled { - return fallbackWidth, fallbackHeight - } - - primary, ok := result.(*screen.Screen) - if !ok || primary == nil { - return fallbackWidth, fallbackHeight - } - - width := primary.WorkArea.Width - height := primary.WorkArea.Height - if width <= 0 || height <= 0 { - width = primary.Bounds.Width - height = primary.Bounds.Height - } - if width <= 0 || height <= 0 { - return fallbackWidth, fallbackHeight - } - - return width, height + _, _, err := s.Core().PERFORM(window.TaskSnapWindow{Name: name, Position: position.String()}) + return err } // StackWindows arranges windows in a cascade pattern. func (s *Service) StackWindows(windowNames []string, offsetX, offsetY int) error { - ws := s.windowService() - if ws == nil { - return coreerr.E("display.StackWindows", "window service not available", nil) - } - return ws.Manager().StackWindows(windowNames, offsetX, offsetY) + _, _, err := s.Core().PERFORM(window.TaskStackWindows{Windows: windowNames, OffsetX: offsetX, OffsetY: offsetY}) + return err } // ApplyWorkflowLayout applies a predefined layout for a specific workflow. func (s *Service) ApplyWorkflowLayout(workflow window.WorkflowLayout) error { - ws := s.windowService() - if ws == nil { - return coreerr.E("display.ApplyWorkflowLayout", "window service not available", nil) - } - screenWidth, screenHeight := s.primaryScreenSize() - return ws.Manager().ApplyWorkflow(workflow, ws.Manager().List(), screenWidth, screenHeight) + _, _, err := s.Core().PERFORM(window.TaskApplyWorkflow{ + Workflow: workflow.String(), + }) + return err } // GetEventManager returns the event manager for WebSocket event subscriptions. @@ -1022,11 +897,12 @@ func ptr[T any](v T) *T { return &v } func (s *Service) handleNewWorkspace() { _, _, _ = s.Core().PERFORM(window.TaskOpenWindow{ - Options: []window.WindowOption{ - window.WithName("workspace-new"), - window.WithTitle("New Workspace"), - window.WithURL("/workspace/new"), - window.WithSize(500, 400), + Window: &window.Window{ + Name: "workspace-new", + Title: "New Workspace", + URL: "/workspace/new", + Width: 500, + Height: 400, }, }) } @@ -1045,11 +921,12 @@ func (s *Service) handleListWorkspaces() { func (s *Service) handleNewFile() { _, _, _ = s.Core().PERFORM(window.TaskOpenWindow{ - Options: []window.WindowOption{ - window.WithName("editor"), - window.WithTitle("New File - Editor"), - window.WithURL("/#/developer/editor?new=true"), - window.WithSize(1200, 800), + Window: &window.Window{ + Name: "editor", + Title: "New File - Editor", + URL: "/#/developer/editor?new=true", + Width: 1200, + Height: 800, }, }) } @@ -1069,11 +946,12 @@ func (s *Service) handleOpenFile() { return } _, _, _ = s.Core().PERFORM(window.TaskOpenWindow{ - Options: []window.WindowOption{ - window.WithName("editor"), - window.WithTitle(paths[0] + " - Editor"), - window.WithURL("/#/developer/editor?file=" + paths[0]), - window.WithSize(1200, 800), + Window: &window.Window{ + Name: "editor", + Title: paths[0] + " - Editor", + URL: "/#/developer/editor?file=" + paths[0], + Width: 1200, + Height: 800, }, }) } @@ -1081,21 +959,23 @@ func (s *Service) handleOpenFile() { func (s *Service) handleSaveFile() { _ = s.Core().ACTION(ActionIDECommand{Command: "save"}) } func (s *Service) handleOpenEditor() { _, _, _ = s.Core().PERFORM(window.TaskOpenWindow{ - Options: []window.WindowOption{ - window.WithName("editor"), - window.WithTitle("Editor"), - window.WithURL("/#/developer/editor"), - window.WithSize(1200, 800), + Window: &window.Window{ + Name: "editor", + Title: "Editor", + URL: "/#/developer/editor", + Width: 1200, + Height: 800, }, }) } func (s *Service) handleOpenTerminal() { _, _, _ = s.Core().PERFORM(window.TaskOpenWindow{ - Options: []window.WindowOption{ - window.WithName("terminal"), - window.WithTitle("Terminal"), - window.WithURL("/#/developer/terminal"), - window.WithSize(800, 500), + Window: &window.Window{ + Name: "terminal", + Title: "Terminal", + URL: "/#/developer/terminal", + Width: 800, + Height: 500, }, }) } diff --git a/pkg/display/display_test.go b/pkg/display/display_test.go index 0c49729..acb9c68 100644 --- a/pkg/display/display_test.go +++ b/pkg/display/display_test.go @@ -121,7 +121,7 @@ func TestServiceConclave_Good(t *testing.T) { // Open a window via IPC result, handled, err := c.PERFORM(window.TaskOpenWindow{ - Options: []window.WindowOption{window.WithName("main")}, + Window: &window.Window{Name: "main"}, }) require.NoError(t, err) assert.True(t, handled) @@ -413,7 +413,7 @@ func TestHandleIPCEvents_WindowOpened_Good(t *testing.T) { // Open a window — this should trigger ActionWindowOpened // which HandleIPCEvents should convert to a WS event result, handled, err := c.PERFORM(window.TaskOpenWindow{ - Options: []window.WindowOption{window.WithName("test")}, + Window: &window.Window{Name: "test"}, }) require.NoError(t, err) assert.True(t, handled) diff --git a/pkg/mcp/tools_layout.go b/pkg/mcp/tools_layout.go index 18066d3..1719ce5 100644 --- a/pkg/mcp/tools_layout.go +++ b/pkg/mcp/tools_layout.go @@ -136,6 +136,43 @@ func (s *Subsystem) layoutSnap(_ context.Context, _ *mcp.CallToolRequest, input return nil, LayoutSnapOutput{Success: true}, nil } +// --- layout_stack --- + +type LayoutStackInput struct { + Windows []string `json:"windows,omitempty"` + OffsetX int `json:"offsetX"` + OffsetY int `json:"offsetY"` +} +type LayoutStackOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) layoutStack(_ context.Context, _ *mcp.CallToolRequest, input LayoutStackInput) (*mcp.CallToolResult, LayoutStackOutput, error) { + _, _, err := s.core.PERFORM(window.TaskStackWindows{Windows: input.Windows, OffsetX: input.OffsetX, OffsetY: input.OffsetY}) + if err != nil { + return nil, LayoutStackOutput{}, err + } + return nil, LayoutStackOutput{Success: true}, nil +} + +// --- layout_workflow --- + +type LayoutWorkflowInput struct { + Workflow string `json:"workflow"` + Windows []string `json:"windows,omitempty"` +} +type LayoutWorkflowOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) layoutWorkflow(_ context.Context, _ *mcp.CallToolRequest, input LayoutWorkflowInput) (*mcp.CallToolResult, LayoutWorkflowOutput, error) { + _, _, err := s.core.PERFORM(window.TaskApplyWorkflow{Workflow: input.Workflow, Windows: input.Windows}) + if err != nil { + return nil, LayoutWorkflowOutput{}, err + } + return nil, LayoutWorkflowOutput{Success: true}, nil +} + // --- Registration --- func (s *Subsystem) registerLayoutTools(server *mcp.Server) { @@ -146,4 +183,6 @@ func (s *Subsystem) registerLayoutTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{Name: "layout_get", Description: "Get a specific layout by name"}, s.layoutGet) mcp.AddTool(server, &mcp.Tool{Name: "layout_tile", Description: "Tile windows in a grid arrangement"}, s.layoutTile) mcp.AddTool(server, &mcp.Tool{Name: "layout_snap", Description: "Snap a window to a screen edge or corner"}, s.layoutSnap) + mcp.AddTool(server, &mcp.Tool{Name: "layout_stack", Description: "Stack windows in a cascade pattern"}, s.layoutStack) + mcp.AddTool(server, &mcp.Tool{Name: "layout_workflow", Description: "Apply a preset workflow layout"}, s.layoutWorkflow) } diff --git a/pkg/mcp/tools_screen.go b/pkg/mcp/tools_screen.go index a89e879..7f86e7e 100644 --- a/pkg/mcp/tools_screen.go +++ b/pkg/mcp/tools_screen.go @@ -6,6 +6,7 @@ import ( coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/gui/pkg/screen" + "forge.lthn.ai/core/gui/pkg/window" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -109,6 +110,34 @@ func (s *Subsystem) screenWorkAreas(_ context.Context, _ *mcp.CallToolRequest, _ return nil, ScreenWorkAreasOutput{WorkAreas: areas}, nil } +// --- screen_for_window --- + +type ScreenForWindowInput struct { + Name string `json:"name"` +} +type ScreenForWindowOutput struct { + Screen *screen.Screen `json:"screen"` +} + +func (s *Subsystem) screenForWindow(_ context.Context, _ *mcp.CallToolRequest, input ScreenForWindowInput) (*mcp.CallToolResult, ScreenForWindowOutput, error) { + result, _, err := s.core.QUERY(window.QueryWindowByName{Name: input.Name}) + if err != nil { + return nil, ScreenForWindowOutput{}, err + } + info, _ := result.(*window.WindowInfo) + if info == nil { + return nil, ScreenForWindowOutput{}, nil + } + centerX := info.X + info.Width/2 + centerY := info.Y + info.Height/2 + screenResult, _, err := s.core.QUERY(screen.QueryAtPoint{X: centerX, Y: centerY}) + if err != nil { + return nil, ScreenForWindowOutput{}, err + } + scr, _ := screenResult.(*screen.Screen) + return nil, ScreenForWindowOutput{Screen: scr}, nil +} + // --- Registration --- func (s *Subsystem) registerScreenTools(server *mcp.Server) { @@ -117,4 +146,5 @@ func (s *Subsystem) registerScreenTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{Name: "screen_primary", Description: "Get the primary screen"}, s.screenPrimary) mcp.AddTool(server, &mcp.Tool{Name: "screen_at_point", Description: "Get the screen at a specific point"}, s.screenAtPoint) mcp.AddTool(server, &mcp.Tool{Name: "screen_work_areas", Description: "Get work areas for all screens"}, s.screenWorkAreas) + mcp.AddTool(server, &mcp.Tool{Name: "screen_for_window", Description: "Get the screen containing a window"}, s.screenForWindow) } diff --git a/pkg/mcp/tools_window.go b/pkg/mcp/tools_window.go index e10c349..b735ed0 100644 --- a/pkg/mcp/tools_window.go +++ b/pkg/mcp/tools_window.go @@ -89,22 +89,17 @@ type WindowCreateOutput struct { } func (s *Subsystem) windowCreate(_ context.Context, _ *mcp.CallToolRequest, input WindowCreateInput) (*mcp.CallToolResult, WindowCreateOutput, error) { - options := []window.WindowOption{ - window.WithName(input.Name), - } - if input.Title != "" { - options = append(options, window.WithTitle(input.Title)) - } - if input.URL != "" { - options = append(options, window.WithURL(input.URL)) - } - if input.Width > 0 || input.Height > 0 { - options = append(options, window.WithSize(input.Width, input.Height)) - } - if input.X != 0 || input.Y != 0 { - options = append(options, window.WithPosition(input.X, input.Y)) - } - result, _, err := s.core.PERFORM(window.TaskOpenWindow{Options: options}) + result, _, err := s.core.PERFORM(window.TaskOpenWindow{ + Window: &window.Window{ + Name: input.Name, + Title: input.Title, + URL: input.URL, + Width: input.Width, + Height: input.Height, + X: input.X, + Y: input.Y, + }, + }) if err != nil { return nil, WindowCreateOutput{}, err } @@ -281,6 +276,27 @@ func (s *Subsystem) windowTitle(_ context.Context, _ *mcp.CallToolRequest, input return nil, WindowTitleOutput{Success: true}, nil } +// --- window_title_get --- + +type WindowTitleGetInput struct { + Name string `json:"name"` +} +type WindowTitleGetOutput struct { + Title string `json:"title"` +} + +func (s *Subsystem) windowTitleGet(_ context.Context, _ *mcp.CallToolRequest, input WindowTitleGetInput) (*mcp.CallToolResult, WindowTitleGetOutput, error) { + result, _, err := s.core.QUERY(window.QueryWindowByName{Name: input.Name}) + if err != nil { + return nil, WindowTitleGetOutput{}, err + } + info, _ := result.(*window.WindowInfo) + if info == nil { + return nil, WindowTitleGetOutput{}, nil + } + return nil, WindowTitleGetOutput{Title: info.Title}, nil +} + // --- window_visibility --- type WindowVisibilityInput struct { @@ -299,6 +315,47 @@ func (s *Subsystem) windowVisibility(_ context.Context, _ *mcp.CallToolRequest, return nil, WindowVisibilityOutput{Success: true}, nil } +// --- window_always_on_top --- + +type WindowAlwaysOnTopInput struct { + Name string `json:"name"` + AlwaysOnTop bool `json:"alwaysOnTop"` +} +type WindowAlwaysOnTopOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) windowAlwaysOnTop(_ context.Context, _ *mcp.CallToolRequest, input WindowAlwaysOnTopInput) (*mcp.CallToolResult, WindowAlwaysOnTopOutput, error) { + _, _, err := s.core.PERFORM(window.TaskSetAlwaysOnTop{Name: input.Name, AlwaysOnTop: input.AlwaysOnTop}) + if err != nil { + return nil, WindowAlwaysOnTopOutput{}, err + } + return nil, WindowAlwaysOnTopOutput{Success: true}, nil +} + +// --- window_background_colour --- + +type WindowBackgroundColourInput struct { + Name string `json:"name"` + Red uint8 `json:"red"` + Green uint8 `json:"green"` + Blue uint8 `json:"blue"` + Alpha uint8 `json:"alpha"` +} +type WindowBackgroundColourOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) windowBackgroundColour(_ context.Context, _ *mcp.CallToolRequest, input WindowBackgroundColourInput) (*mcp.CallToolResult, WindowBackgroundColourOutput, error) { + _, _, err := s.core.PERFORM(window.TaskSetBackgroundColour{ + Name: input.Name, Red: input.Red, Green: input.Green, Blue: input.Blue, Alpha: input.Alpha, + }) + if err != nil { + return nil, WindowBackgroundColourOutput{}, err + } + return nil, WindowBackgroundColourOutput{Success: true}, nil +} + // --- window_fullscreen --- type WindowFullscreenInput struct { @@ -333,6 +390,9 @@ func (s *Subsystem) registerWindowTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{Name: "window_restore", Description: "Restore a maximised or minimised window"}, s.windowRestore) mcp.AddTool(server, &mcp.Tool{Name: "window_focus", Description: "Bring a window to the front"}, s.windowFocus) mcp.AddTool(server, &mcp.Tool{Name: "window_title", Description: "Set the title of a window"}, s.windowTitle) + mcp.AddTool(server, &mcp.Tool{Name: "window_title_get", Description: "Get the title of a window"}, s.windowTitleGet) mcp.AddTool(server, &mcp.Tool{Name: "window_visibility", Description: "Show or hide a window"}, s.windowVisibility) + mcp.AddTool(server, &mcp.Tool{Name: "window_always_on_top", Description: "Pin a window above others"}, s.windowAlwaysOnTop) + mcp.AddTool(server, &mcp.Tool{Name: "window_background_colour", Description: "Set a window background colour"}, s.windowBackgroundColour) mcp.AddTool(server, &mcp.Tool{Name: "window_fullscreen", Description: "Set a window to fullscreen mode"}, s.windowFullscreen) } diff --git a/pkg/systray/menu.go b/pkg/systray/menu.go index df2bdaa..848ee1a 100644 --- a/pkg/systray/menu.go +++ b/pkg/systray/menu.go @@ -8,14 +8,14 @@ func (m *Manager) SetMenu(items []TrayMenuItem) error { if m.tray == nil { return fmt.Errorf("tray not initialised") } - menu := m.buildMenu(items) + menu := m.platform.NewMenu() + m.buildMenu(menu, items) m.tray.SetMenu(menu) return nil } // buildMenu recursively builds a PlatformMenu from TrayMenuItem descriptors. -func (m *Manager) buildMenu(items []TrayMenuItem) PlatformMenu { - menu := m.platform.NewMenu() +func (m *Manager) buildMenu(menu PlatformMenu, items []TrayMenuItem) { for _, item := range items { if item.Type == "separator" { menu.AddSeparator() @@ -45,7 +45,6 @@ func (m *Manager) buildMenu(items []TrayMenuItem) PlatformMenu { }) } } - return menu } // RegisterCallback registers a callback for a menu action ID. diff --git a/pkg/systray/tray_test.go b/pkg/systray/tray_test.go index 68b7feb..6de6d39 100644 --- a/pkg/systray/tray_test.go +++ b/pkg/systray/tray_test.go @@ -87,23 +87,26 @@ func TestManager_GetInfo_Good(t *testing.T) { func TestManager_Build_Submenu_Recursive_Good(t *testing.T) { m, p := newTestManager() - items := []MenuItem{ + require.NoError(t, m.Setup("Core", "Core")) + + items := []TrayMenuItem{ { Label: "Parent", - Children: []MenuItem{ + Submenu: []TrayMenuItem{ {Label: "Child 1"}, {Label: "Child 2"}, }, }, } - menu := m.Build(items) - assert.NotNil(t, menu) + require.NoError(t, m.SetMenu(items)) require.Len(t, p.menus, 1) - require.Len(t, p.menus[0].items, 1) - assert.Equal(t, "Parent", p.menus[0].items[0]) - require.Len(t, p.menus[0].subs, 1) - require.Len(t, p.menus[0].subs[0].items, 2) - assert.Equal(t, "Child 1", p.menus[0].subs[0].items[0]) - assert.Equal(t, "Child 2", p.menus[0].subs[0].items[1]) + + menu := p.menus[0] + require.Len(t, menu.items, 1) + assert.Equal(t, "Parent", menu.items[0]) + require.Len(t, menu.subs, 1) + require.Len(t, menu.subs[0].items, 2) + assert.Equal(t, "Child 1", menu.subs[0].items[0]) + assert.Equal(t, "Child 2", menu.subs[0].items[1]) } diff --git a/pkg/window/messages.go b/pkg/window/messages.go index ece680a..a14d464 100644 --- a/pkg/window/messages.go +++ b/pkg/window/messages.go @@ -17,7 +17,10 @@ type QueryWindowByName struct{ Name string } type QueryConfig struct{} -type TaskOpenWindow struct{ Options []WindowOption } +type TaskOpenWindow struct { + Window *Window + Options []WindowOption +} type TaskCloseWindow struct{ Name string } @@ -44,6 +47,19 @@ type TaskSetTitle struct { Title string } +type TaskSetAlwaysOnTop struct { + Name string + AlwaysOnTop bool +} + +type TaskSetBackgroundColour struct { + Name string + Red uint8 + Green uint8 + Blue uint8 + Alpha uint8 +} + type TaskSetVisibility struct { Name string Visible bool @@ -69,11 +85,22 @@ type TaskTileWindows struct { Windows []string // window names; empty = all } +type TaskStackWindows struct { + Windows []string // window names; empty = all + OffsetX int + OffsetY int +} + type TaskSnapWindow struct { Name string // window name Position string // "left", "right", "top", "bottom", "top-left", "top-right", "bottom-left", "bottom-right", "center" } +type TaskApplyWorkflow struct { + Workflow string + Windows []string // window names; empty = all +} + type TaskSaveConfig struct{ Config map[string]any } type ActionWindowOpened struct{ Name string } diff --git a/pkg/window/mock_platform.go b/pkg/window/mock_platform.go index 1d3176c..762f9d4 100644 --- a/pkg/window/mock_platform.go +++ b/pkg/window/mock_platform.go @@ -33,6 +33,7 @@ type MockWindow struct { width, height, x, y int maximised, focused bool visible, alwaysOnTop bool + backgroundColour [4]uint8 closed bool eventHandlers []func(WindowEvent) fileDropHandlers []func(paths []string, targetID string) @@ -47,7 +48,7 @@ func (w *MockWindow) IsFocused() bool { return w.focused } func (w *MockWindow) SetTitle(title string) { w.title = title } func (w *MockWindow) SetPosition(x, y int) { w.x = x; w.y = y } func (w *MockWindow) SetSize(width, height int) { w.width = width; w.height = height } -func (w *MockWindow) SetBackgroundColour(r, g, b, a uint8) {} +func (w *MockWindow) SetBackgroundColour(r, g, b, a uint8) { w.backgroundColour = [4]uint8{r, g, b, a} } func (w *MockWindow) SetVisibility(visible bool) { w.visible = visible } func (w *MockWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop } func (w *MockWindow) Maximise() { w.maximised = true } diff --git a/pkg/window/mock_test.go b/pkg/window/mock_test.go index 0babb6a..a932756 100644 --- a/pkg/window/mock_test.go +++ b/pkg/window/mock_test.go @@ -31,6 +31,7 @@ type mockWindow struct { width, height, x, y int maximised, focused bool visible, alwaysOnTop bool + backgroundColour [4]uint8 closed bool minimised bool fullscreened bool @@ -47,7 +48,7 @@ func (w *mockWindow) IsFocused() bool { return w.focused } func (w *mockWindow) SetTitle(title string) { w.title = title } func (w *mockWindow) SetPosition(x, y int) { w.x = x; w.y = y } func (w *mockWindow) SetSize(width, height int) { w.width = width; w.height = height } -func (w *mockWindow) SetBackgroundColour(r, g, b, a uint8) {} +func (w *mockWindow) SetBackgroundColour(r, g, b, a uint8) { w.backgroundColour = [4]uint8{r, g, b, a} } func (w *mockWindow) SetVisibility(visible bool) { w.visible = visible } func (w *mockWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop } func (w *mockWindow) Maximise() { w.maximised = true } diff --git a/pkg/window/service.go b/pkg/window/service.go index a8f2884..1ae5d8a 100644 --- a/pkg/window/service.go +++ b/pkg/window/service.go @@ -125,6 +125,10 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { return nil, true, s.taskRestore(t.Name) case TaskSetTitle: return nil, true, s.taskSetTitle(t.Name, t.Title) + case TaskSetAlwaysOnTop: + return nil, true, s.taskSetAlwaysOnTop(t.Name, t.AlwaysOnTop) + case TaskSetBackgroundColour: + return nil, true, s.taskSetBackgroundColour(t.Name, t.Red, t.Green, t.Blue, t.Alpha) case TaskSetVisibility: return nil, true, s.taskSetVisibility(t.Name, t.Visible) case TaskFullscreen: @@ -138,42 +142,60 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { return nil, true, nil case TaskTileWindows: return nil, true, s.taskTileWindows(t.Mode, t.Windows) + case TaskStackWindows: + return nil, true, s.taskStackWindows(t.Windows, t.OffsetX, t.OffsetY) case TaskSnapWindow: return nil, true, s.taskSnapWindow(t.Name, t.Position) + case TaskApplyWorkflow: + return nil, true, s.taskApplyWorkflow(t.Workflow, t.Windows) default: return nil, false, nil } } -func (s *Service) primaryScreenSize() (int, int) { +func (s *Service) primaryScreenArea() (int, int, int, int) { + const fallbackX = 0 + const fallbackY = 0 const fallbackWidth = 1920 const fallbackHeight = 1080 result, handled, err := s.Core().QUERY(screen.QueryPrimary{}) if err != nil || !handled { - return fallbackWidth, fallbackHeight + return fallbackX, fallbackY, fallbackWidth, fallbackHeight } primary, ok := result.(*screen.Screen) if !ok || primary == nil { - return fallbackWidth, fallbackHeight + return fallbackX, fallbackY, fallbackWidth, fallbackHeight } + x := primary.WorkArea.X + y := primary.WorkArea.Y width := primary.WorkArea.Width height := primary.WorkArea.Height if width <= 0 || height <= 0 { + x = primary.Bounds.X + y = primary.Bounds.Y width = primary.Bounds.Width height = primary.Bounds.Height } if width <= 0 || height <= 0 { - return fallbackWidth, fallbackHeight + return fallbackX, fallbackY, fallbackWidth, fallbackHeight } - return width, height + return x, y, width, height } func (s *Service) taskOpenWindow(t TaskOpenWindow) (any, bool, error) { - pw, err := s.manager.Open(t.Options...) + var ( + pw PlatformWindow + err error + ) + if t.Window != nil { + pw, err = s.manager.Create(t.Window) + } else { + pw, err = s.manager.Open(t.Options...) + } if err != nil { return nil, true, err } @@ -302,6 +324,24 @@ func (s *Service) taskSetTitle(name, title string) error { return nil } +func (s *Service) taskSetAlwaysOnTop(name string, alwaysOnTop bool) error { + pw, ok := s.manager.Get(name) + if !ok { + return fmt.Errorf("window not found: %s", name) + } + pw.SetAlwaysOnTop(alwaysOnTop) + return nil +} + +func (s *Service) taskSetBackgroundColour(name string, red, green, blue, alpha uint8) error { + pw, ok := s.manager.Get(name) + if !ok { + return fmt.Errorf("window not found: %s", name) + } + pw.SetBackgroundColour(red, green, blue, alpha) + return nil +} + func (s *Service) taskSetVisibility(name string, visible bool) error { pw, ok := s.manager.Get(name) if !ok { @@ -350,7 +390,10 @@ func (s *Service) taskRestoreLayout(name string) error { pw.SetSize(state.Width, state.Height) if state.Maximized { pw.Maximise() + } else { + pw.Restore() } + s.manager.State().CaptureState(pw) } return nil } @@ -371,8 +414,16 @@ func (s *Service) taskTileWindows(mode string, names []string) error { if len(names) == 0 { names = s.manager.List() } - screenWidth, screenHeight := s.primaryScreenSize() - return s.manager.TileWindows(tm, names, screenWidth, screenHeight) + originX, originY, screenWidth, screenHeight := s.primaryScreenArea() + return s.manager.TileWindows(tm, names, screenWidth, screenHeight, originX, originY) +} + +func (s *Service) taskStackWindows(names []string, offsetX, offsetY int) error { + if len(names) == 0 { + names = s.manager.List() + } + originX, originY, _, _ := s.primaryScreenArea() + return s.manager.StackWindows(names, offsetX, offsetY, originX, originY) } var snapPosMap = map[string]SnapPosition{ @@ -388,8 +439,27 @@ func (s *Service) taskSnapWindow(name, position string) error { if !ok { return fmt.Errorf("unknown snap position: %s", position) } - screenWidth, screenHeight := s.primaryScreenSize() - return s.manager.SnapWindow(name, pos, screenWidth, screenHeight) + originX, originY, screenWidth, screenHeight := s.primaryScreenArea() + return s.manager.SnapWindow(name, pos, screenWidth, screenHeight, originX, originY) +} + +var workflowLayoutMap = map[string]WorkflowLayout{ + "coding": WorkflowCoding, + "debugging": WorkflowDebugging, + "presenting": WorkflowPresenting, + "side-by-side": WorkflowSideBySide, +} + +func (s *Service) taskApplyWorkflow(workflow string, names []string) error { + layout, ok := workflowLayoutMap[workflow] + if !ok { + return fmt.Errorf("unknown workflow layout: %s", workflow) + } + if len(names) == 0 { + names = s.manager.List() + } + originX, originY, screenWidth, screenHeight := s.primaryScreenArea() + return s.manager.ApplyWorkflow(layout, names, screenWidth, screenHeight, originX, originY) } // Manager returns the underlying window Manager for direct access. diff --git a/pkg/window/service_screen_test.go b/pkg/window/service_screen_test.go index 1541dea..117d70c 100644 --- a/pkg/window/service_screen_test.go +++ b/pkg/window/service_screen_test.go @@ -97,3 +97,38 @@ func TestTaskSnapWindow_UsesPrimaryScreenSize(t *testing.T) { assert.Equal(t, 1000, info.Width) assert.Equal(t, 1000, info.Height) } + +func TestTaskTileWindows_UsesPrimaryWorkAreaOrigin(t *testing.T) { + _, c := newTestWindowServiceWithScreen(t, []screen.Screen{ + { + ID: "1", Name: "Primary", IsPrimary: true, + Bounds: screen.Rect{X: 0, Y: 0, Width: 2000, Height: 1000}, + WorkArea: screen.Rect{X: 100, Y: 50, Width: 2000, Height: 1000}, + }, + }) + + _, _, err := c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("left"), WithSize(400, 400)}}) + require.NoError(t, err) + _, _, err = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("right"), WithSize(400, 400)}}) + require.NoError(t, err) + + _, handled, err := c.PERFORM(TaskTileWindows{Mode: "left-right", Windows: []string{"left", "right"}}) + require.NoError(t, err) + assert.True(t, handled) + + result, _, err := c.QUERY(QueryWindowByName{Name: "left"}) + require.NoError(t, err) + left := result.(*WindowInfo) + assert.Equal(t, 100, left.X) + assert.Equal(t, 50, left.Y) + assert.Equal(t, 1000, left.Width) + assert.Equal(t, 1000, left.Height) + + result, _, err = c.QUERY(QueryWindowByName{Name: "right"}) + require.NoError(t, err) + right := result.(*WindowInfo) + assert.Equal(t, 1100, right.X) + assert.Equal(t, 50, right.Y) + assert.Equal(t, 1000, right.Width) + assert.Equal(t, 1000, right.Height) +} diff --git a/pkg/window/service_test.go b/pkg/window/service_test.go index 57fb9f4..6cdfc1a 100644 --- a/pkg/window/service_test.go +++ b/pkg/window/service_test.go @@ -31,7 +31,7 @@ func TestRegister_Good(t *testing.T) { func TestTaskOpenWindow_Good(t *testing.T) { _, c := newTestWindowService(t) result, handled, err := c.PERFORM(TaskOpenWindow{ - Options: []WindowOption{WithName("test"), WithURL("/")}, + Window: &Window{Name: "test", URL: "/"}, }) require.NoError(t, err) assert.True(t, handled) @@ -39,6 +39,17 @@ func TestTaskOpenWindow_Good(t *testing.T) { assert.Equal(t, "test", info.Name) } +func TestTaskOpenWindow_OptionsFallback_Good(t *testing.T) { + _, c := newTestWindowService(t) + result, handled, err := c.PERFORM(TaskOpenWindow{ + Options: []WindowOption{WithName("test-fallback"), WithURL("/")}, + }) + require.NoError(t, err) + assert.True(t, handled) + info := result.(WindowInfo) + assert.Equal(t, "test-fallback", info.Name) +} + func TestTaskOpenWindow_Bad(t *testing.T) { // No window service registered — PERFORM returns handled=false c, err := core.New(core.WithServiceLock()) @@ -274,6 +285,54 @@ func TestTaskSetTitle_Bad(t *testing.T) { assert.Error(t, err) } +// --- TaskSetAlwaysOnTop --- + +func TestTaskSetAlwaysOnTop_Good(t *testing.T) { + svc, c := newTestWindowService(t) + _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}}) + + _, handled, err := c.PERFORM(TaskSetAlwaysOnTop{Name: "test", AlwaysOnTop: true}) + require.NoError(t, err) + assert.True(t, handled) + + pw, ok := svc.Manager().Get("test") + require.True(t, ok) + mw := pw.(*mockWindow) + assert.True(t, mw.alwaysOnTop) +} + +func TestTaskSetAlwaysOnTop_Bad(t *testing.T) { + _, c := newTestWindowService(t) + _, handled, err := c.PERFORM(TaskSetAlwaysOnTop{Name: "nonexistent", AlwaysOnTop: true}) + assert.True(t, handled) + assert.Error(t, err) +} + +// --- TaskSetBackgroundColour --- + +func TestTaskSetBackgroundColour_Good(t *testing.T) { + svc, c := newTestWindowService(t) + _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}}) + + _, handled, err := c.PERFORM(TaskSetBackgroundColour{ + Name: "test", Red: 10, Green: 20, Blue: 30, Alpha: 40, + }) + require.NoError(t, err) + assert.True(t, handled) + + pw, ok := svc.Manager().Get("test") + require.True(t, ok) + mw := pw.(*mockWindow) + assert.Equal(t, [4]uint8{10, 20, 30, 40}, mw.backgroundColour) +} + +func TestTaskSetBackgroundColour_Bad(t *testing.T) { + _, c := newTestWindowService(t) + _, handled, err := c.PERFORM(TaskSetBackgroundColour{Name: "nonexistent", Red: 1, Green: 2, Blue: 3, Alpha: 4}) + assert.True(t, handled) + assert.Error(t, err) +} + // --- TaskSetVisibility --- func TestTaskSetVisibility_Good(t *testing.T) { @@ -401,6 +460,16 @@ func TestTaskRestoreLayout_Good(t *testing.T) { x2, y2 := pw2.Position() assert.Equal(t, 0, x2) assert.Equal(t, 0, y2) + + editorState, ok := svc.Manager().State().GetState("editor") + require.True(t, ok) + assert.Equal(t, 0, editorState.X) + assert.Equal(t, 0, editorState.Y) + + terminalState, ok := svc.Manager().State().GetState("terminal") + require.True(t, ok) + assert.Equal(t, 0, terminalState.X) + assert.Equal(t, 0, terminalState.Y) } func TestTaskRestoreLayout_Bad(t *testing.T) { @@ -409,3 +478,45 @@ func TestTaskRestoreLayout_Bad(t *testing.T) { assert.True(t, handled) assert.Error(t, err) } + +// --- TaskStackWindows --- + +func TestTaskStackWindows_Good(t *testing.T) { + svc, c := newTestWindowService(t) + _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("s1"), WithSize(800, 600)}}) + _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("s2"), WithSize(800, 600)}}) + + _, handled, err := c.PERFORM(TaskStackWindows{Windows: []string{"s1", "s2"}, OffsetX: 25, OffsetY: 35}) + require.NoError(t, err) + assert.True(t, handled) + + pw, ok := svc.Manager().Get("s2") + require.True(t, ok) + x, y := pw.Position() + assert.Equal(t, 25, x) + assert.Equal(t, 35, y) +} + +// --- TaskApplyWorkflow --- + +func TestTaskApplyWorkflow_Good(t *testing.T) { + svc, c := newTestWindowService(t) + _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("editor"), WithSize(800, 600)}}) + _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("terminal"), WithSize(800, 600)}}) + + _, handled, err := c.PERFORM(TaskApplyWorkflow{Workflow: "side-by-side"}) + require.NoError(t, err) + assert.True(t, handled) + + editor, ok := svc.Manager().Get("editor") + require.True(t, ok) + x, y := editor.Position() + assert.Equal(t, 0, x) + assert.Equal(t, 0, y) + + terminal, ok := svc.Manager().Get("terminal") + require.True(t, ok) + x, y = terminal.Position() + assert.Equal(t, 960, x) + assert.Equal(t, 0, y) +} diff --git a/pkg/window/tiling.go b/pkg/window/tiling.go index e6ee430..6137e89 100644 --- a/pkg/window/tiling.go +++ b/pkg/window/tiling.go @@ -44,6 +44,16 @@ const ( SnapCenter ) +var snapPositionNames = map[SnapPosition]string{ + SnapLeft: "left", SnapRight: "right", + SnapTop: "top", SnapBottom: "bottom", + SnapTopLeft: "top-left", SnapTopRight: "top-right", + SnapBottomLeft: "bottom-left", SnapBottomRight: "bottom-right", + SnapCenter: "center", +} + +func (p SnapPosition) String() string { return snapPositionNames[p] } + // WorkflowLayout is a predefined arrangement for common tasks. type WorkflowLayout int @@ -61,8 +71,26 @@ var workflowNames = map[WorkflowLayout]string{ func (w WorkflowLayout) String() string { return workflowNames[w] } +func layoutOrigin(origin []int) (int, int) { + if len(origin) == 0 { + return 0, 0 + } + if len(origin) == 1 { + return origin[0], 0 + } + return origin[0], origin[1] +} + +func (m *Manager) captureState(pw PlatformWindow) { + if m.state == nil || pw == nil { + return + } + m.state.CaptureState(pw) +} + // TileWindows arranges the named windows in the given mode across the screen area. -func (m *Manager) TileWindows(mode TileMode, names []string, screenW, screenH int) error { +func (m *Manager) TileWindows(mode TileMode, names []string, screenW, screenH int, origin ...int) error { + originX, originY := layoutOrigin(origin) windows := make([]PlatformWindow, 0, len(names)) for _, name := range names { pw, ok := m.Get(name) @@ -81,8 +109,9 @@ func (m *Manager) TileWindows(mode TileMode, names []string, screenW, screenH in case TileModeLeftRight: w := screenW / len(windows) for i, pw := range windows { - pw.SetPosition(i*w, 0) + pw.SetPosition(originX+i*w, originY) pw.SetSize(w, screenH) + m.captureState(pw) } case TileModeGrid: cols := 2 @@ -95,55 +124,65 @@ func (m *Manager) TileWindows(mode TileMode, names []string, screenW, screenH in col := i % cols rows := (len(windows) + cols - 1) / cols cellH := screenH / rows - pw.SetPosition(col*cellW, row*cellH) + pw.SetPosition(originX+col*cellW, originY+row*cellH) pw.SetSize(cellW, cellH) + m.captureState(pw) } case TileModeLeftHalf: for _, pw := range windows { - pw.SetPosition(0, 0) + pw.SetPosition(originX, originY) pw.SetSize(halfW, screenH) + m.captureState(pw) } case TileModeRightHalf: for _, pw := range windows { - pw.SetPosition(halfW, 0) + pw.SetPosition(originX+halfW, originY) pw.SetSize(halfW, screenH) + m.captureState(pw) } case TileModeTopHalf: for _, pw := range windows { - pw.SetPosition(0, 0) + pw.SetPosition(originX, originY) pw.SetSize(screenW, halfH) + m.captureState(pw) } case TileModeBottomHalf: for _, pw := range windows { - pw.SetPosition(0, halfH) + pw.SetPosition(originX, originY+halfH) pw.SetSize(screenW, halfH) + m.captureState(pw) } case TileModeTopLeft: for _, pw := range windows { - pw.SetPosition(0, 0) + pw.SetPosition(originX, originY) pw.SetSize(halfW, halfH) + m.captureState(pw) } case TileModeTopRight: for _, pw := range windows { - pw.SetPosition(halfW, 0) + pw.SetPosition(originX+halfW, originY) pw.SetSize(halfW, halfH) + m.captureState(pw) } case TileModeBottomLeft: for _, pw := range windows { - pw.SetPosition(0, halfH) + pw.SetPosition(originX, originY+halfH) pw.SetSize(halfW, halfH) + m.captureState(pw) } case TileModeBottomRight: for _, pw := range windows { - pw.SetPosition(halfW, halfH) + pw.SetPosition(originX+halfW, originY+halfH) pw.SetSize(halfW, halfH) + m.captureState(pw) } } return nil } // SnapWindow snaps a window to a screen edge/corner/centre. -func (m *Manager) SnapWindow(name string, pos SnapPosition, screenW, screenH int) error { +func (m *Manager) SnapWindow(name string, pos SnapPosition, screenW, screenH int, origin ...int) error { + originX, originY := layoutOrigin(origin) pw, ok := m.Get(name) if !ok { return coreerr.E("window.Manager.SnapWindow", "window not found: "+name, nil) @@ -153,50 +192,54 @@ func (m *Manager) SnapWindow(name string, pos SnapPosition, screenW, screenH int switch pos { case SnapLeft: - pw.SetPosition(0, 0) + pw.SetPosition(originX, originY) pw.SetSize(halfW, screenH) case SnapRight: - pw.SetPosition(halfW, 0) + pw.SetPosition(originX+halfW, originY) pw.SetSize(halfW, screenH) case SnapTop: - pw.SetPosition(0, 0) + pw.SetPosition(originX, originY) pw.SetSize(screenW, halfH) case SnapBottom: - pw.SetPosition(0, halfH) + pw.SetPosition(originX, originY+halfH) pw.SetSize(screenW, halfH) case SnapTopLeft: - pw.SetPosition(0, 0) + pw.SetPosition(originX, originY) pw.SetSize(halfW, halfH) case SnapTopRight: - pw.SetPosition(halfW, 0) + pw.SetPosition(originX+halfW, originY) pw.SetSize(halfW, halfH) case SnapBottomLeft: - pw.SetPosition(0, halfH) + pw.SetPosition(originX, originY+halfH) pw.SetSize(halfW, halfH) case SnapBottomRight: - pw.SetPosition(halfW, halfH) + pw.SetPosition(originX+halfW, originY+halfH) pw.SetSize(halfW, halfH) case SnapCenter: cw, ch := pw.Size() - pw.SetPosition((screenW-cw)/2, (screenH-ch)/2) + pw.SetPosition(originX+(screenW-cw)/2, originY+(screenH-ch)/2) } + m.captureState(pw) return nil } // StackWindows cascades windows with an offset. -func (m *Manager) StackWindows(names []string, offsetX, offsetY int) error { +func (m *Manager) StackWindows(names []string, offsetX, offsetY int, origin ...int) error { + originX, originY := layoutOrigin(origin) for i, name := range names { pw, ok := m.Get(name) if !ok { return coreerr.E("window.Manager.StackWindows", "window not found: "+name, nil) } - pw.SetPosition(i*offsetX, i*offsetY) + pw.SetPosition(originX+i*offsetX, originY+i*offsetY) + m.captureState(pw) } return nil } // ApplyWorkflow arranges windows in a predefined workflow layout. -func (m *Manager) ApplyWorkflow(workflow WorkflowLayout, names []string, screenW, screenH int) error { +func (m *Manager) ApplyWorkflow(workflow WorkflowLayout, names []string, screenW, screenH int, origin ...int) error { + originX, originY := layoutOrigin(origin) if len(names) == 0 { return coreerr.E("window.Manager.ApplyWorkflow", "no windows for workflow", nil) } @@ -206,36 +249,41 @@ func (m *Manager) ApplyWorkflow(workflow WorkflowLayout, names []string, screenW // 70/30 split — main editor + terminal mainW := screenW * 70 / 100 if pw, ok := m.Get(names[0]); ok { - pw.SetPosition(0, 0) + pw.SetPosition(originX, originY) pw.SetSize(mainW, screenH) + m.captureState(pw) } if len(names) > 1 { if pw, ok := m.Get(names[1]); ok { - pw.SetPosition(mainW, 0) + pw.SetPosition(originX+mainW, originY) pw.SetSize(screenW-mainW, screenH) + m.captureState(pw) } } case WorkflowDebugging: // 60/40 split mainW := screenW * 60 / 100 if pw, ok := m.Get(names[0]); ok { - pw.SetPosition(0, 0) + pw.SetPosition(originX, originY) pw.SetSize(mainW, screenH) + m.captureState(pw) } if len(names) > 1 { if pw, ok := m.Get(names[1]); ok { - pw.SetPosition(mainW, 0) + pw.SetPosition(originX+mainW, originY) pw.SetSize(screenW-mainW, screenH) + m.captureState(pw) } } case WorkflowPresenting: // Maximise first window if pw, ok := m.Get(names[0]); ok { - pw.SetPosition(0, 0) + pw.SetPosition(originX, originY) pw.SetSize(screenW, screenH) + m.captureState(pw) } case WorkflowSideBySide: - return m.TileWindows(TileModeLeftRight, names, screenW, screenH) + return m.TileWindows(TileModeLeftRight, names, screenW, screenH, originX, originY) } return nil } diff --git a/stubs/wails/go.mod b/stubs/wails/go.mod new file mode 100644 index 0000000..7dcb832 --- /dev/null +++ b/stubs/wails/go.mod @@ -0,0 +1,3 @@ +module github.com/wailsapp/wails/v3 + +go 1.26.0 diff --git a/stubs/wails/pkg/application/application.go b/stubs/wails/pkg/application/application.go new file mode 100644 index 0000000..211611c --- /dev/null +++ b/stubs/wails/pkg/application/application.go @@ -0,0 +1,363 @@ +package application + +import ( + "sync" + + "github.com/wailsapp/wails/v3/pkg/events" +) + +// Context mirrors the callback context type exposed by Wails. +type Context struct{} + +// Logger is a minimal logger surface used by the GUI packages. +type Logger struct{} + +func (l Logger) Info(message string, args ...any) {} + +// RGBA stores a colour with alpha. +type RGBA struct { + Red, Green, Blue, Alpha uint8 +} + +// NewRGBA constructs an RGBA value. +func NewRGBA(red, green, blue, alpha uint8) RGBA { + return RGBA{Red: red, Green: green, Blue: blue, Alpha: alpha} +} + +// MenuRole identifies a platform menu role. +type MenuRole int + +const ( + AppMenu MenuRole = iota + FileMenu + EditMenu + ViewMenu + WindowMenu + HelpMenu +) + +// MenuItem is a minimal menu item implementation. +type MenuItem struct { + Label string + Accelerator string + Tooltip string + Checked bool + Enabled bool + onClick func(*Context) +} + +func (mi *MenuItem) SetAccelerator(accel string) { mi.Accelerator = accel } +func (mi *MenuItem) SetTooltip(text string) { mi.Tooltip = text } +func (mi *MenuItem) SetChecked(checked bool) { mi.Checked = checked } +func (mi *MenuItem) SetEnabled(enabled bool) { mi.Enabled = enabled } +func (mi *MenuItem) OnClick(fn func(*Context)) { mi.onClick = fn } + +// Menu is a minimal menu tree used by the GUI wrappers. +type Menu struct { + Items []*MenuItem +} + +func NewMenu() *Menu { return &Menu{} } + +func (m *Menu) Add(label string) *MenuItem { + item := &MenuItem{Label: label, Enabled: true} + m.Items = append(m.Items, item) + return item +} + +func (m *Menu) AddSeparator() { + m.Items = append(m.Items, &MenuItem{Label: "---"}) +} + +func (m *Menu) AddSubmenu(label string) *Menu { + submenu := &Menu{} + m.Items = append(m.Items, &MenuItem{Label: label}) + return submenu +} + +func (m *Menu) AddRole(role MenuRole) { + m.Items = append(m.Items, &MenuItem{Label: role.String(), Enabled: true}) +} + +func (role MenuRole) String() string { + switch role { + case AppMenu: + return "app" + case FileMenu: + return "file" + case EditMenu: + return "edit" + case ViewMenu: + return "view" + case WindowMenu: + return "window" + case HelpMenu: + return "help" + default: + return "unknown" + } +} + +// MenuManager owns the application menu. +type MenuManager struct { + applicationMenu *Menu +} + +func (m *MenuManager) SetApplicationMenu(menu *Menu) { m.applicationMenu = menu } + +// SystemTray represents a tray instance. +type SystemTray struct { + icon []byte + templateIcon []byte + tooltip string + label string + menu *Menu + attachedWindow *WebviewWindow +} + +func (t *SystemTray) SetIcon(data []byte) { t.icon = append([]byte(nil), data...) } +func (t *SystemTray) SetTemplateIcon(data []byte) { t.templateIcon = append([]byte(nil), data...) } +func (t *SystemTray) SetTooltip(text string) { t.tooltip = text } +func (t *SystemTray) SetLabel(text string) { t.label = text } +func (t *SystemTray) SetMenu(menu *Menu) { t.menu = menu } +func (t *SystemTray) AttachWindow(w *WebviewWindow) { + t.attachedWindow = w +} + +// SystemTrayManager creates tray instances. +type SystemTrayManager struct{} + +func (m *SystemTrayManager) New() *SystemTray { return &SystemTray{} } + +// WindowEventContext carries drag-and-drop details for a window event. +type WindowEventContext struct { + droppedFiles []string + dropDetails *DropTargetDetails +} + +func (c *WindowEventContext) DroppedFiles() []string { + return append([]string(nil), c.droppedFiles...) +} + +func (c *WindowEventContext) DropTargetDetails() *DropTargetDetails { + if c.dropDetails == nil { + return nil + } + details := *c.dropDetails + return &details +} + +// DropTargetDetails mirrors the fields consumed by the GUI wrappers. +type DropTargetDetails struct { + ElementID string +} + +// WindowEvent mirrors the event object passed to window callbacks. +type WindowEvent struct { + ctx *WindowEventContext +} + +func (e *WindowEvent) Context() *WindowEventContext { + if e.ctx == nil { + e.ctx = &WindowEventContext{} + } + return e.ctx +} + +// WebviewWindowOptions configures a window instance. +type WebviewWindowOptions struct { + Name string + Title string + URL string + Width, Height int + X, Y int + MinWidth, MinHeight int + MaxWidth, MaxHeight int + Frameless bool + Hidden bool + AlwaysOnTop bool + DisableResize bool + EnableFileDrop bool + BackgroundColour RGBA +} + +// WebviewWindow is a lightweight, in-memory window implementation. +type WebviewWindow struct { + mu sync.RWMutex + opts WebviewWindowOptions + title string + x, y int + width, height int + maximised bool + focused bool + visible bool + alwaysOnTop bool + fullscreen bool + closed bool + eventHandlers map[events.WindowEventType][]func(*WindowEvent) +} + +func newWebviewWindow(options WebviewWindowOptions) *WebviewWindow { + return &WebviewWindow{ + opts: options, + title: options.Title, + x: options.X, + y: options.Y, + width: options.Width, + height: options.Height, + visible: !options.Hidden, + alwaysOnTop: options.AlwaysOnTop, + eventHandlers: make(map[events.WindowEventType][]func(*WindowEvent)), + } +} + +func (w *WebviewWindow) Name() string { return w.opts.Name } +func (w *WebviewWindow) Title() string { + w.mu.RLock() + defer w.mu.RUnlock() + return w.title +} +func (w *WebviewWindow) Position() (int, int) { + w.mu.RLock() + defer w.mu.RUnlock() + return w.x, w.y +} +func (w *WebviewWindow) Size() (int, int) { + w.mu.RLock() + defer w.mu.RUnlock() + return w.width, w.height +} +func (w *WebviewWindow) IsMaximised() bool { + w.mu.RLock() + defer w.mu.RUnlock() + return w.maximised +} +func (w *WebviewWindow) IsFocused() bool { + w.mu.RLock() + defer w.mu.RUnlock() + return w.focused +} + +func (w *WebviewWindow) SetTitle(title string) { + w.mu.Lock() + w.title = title + w.mu.Unlock() +} + +func (w *WebviewWindow) SetPosition(x, y int) { + w.mu.Lock() + w.x = x + w.y = y + w.mu.Unlock() +} + +func (w *WebviewWindow) SetSize(width, height int) { + w.mu.Lock() + w.width = width + w.height = height + w.mu.Unlock() +} + +func (w *WebviewWindow) SetBackgroundColour(colour RGBA) {} + +func (w *WebviewWindow) SetAlwaysOnTop(alwaysOnTop bool) { + w.mu.Lock() + w.alwaysOnTop = alwaysOnTop + w.mu.Unlock() +} + +func (w *WebviewWindow) Maximise() { + w.mu.Lock() + w.maximised = true + w.mu.Unlock() +} + +func (w *WebviewWindow) Restore() { + w.mu.Lock() + w.maximised = false + w.fullscreen = false + w.mu.Unlock() +} + +func (w *WebviewWindow) Minimise() {} + +func (w *WebviewWindow) Focus() { + w.mu.Lock() + w.focused = true + w.mu.Unlock() +} + +func (w *WebviewWindow) Close() { + w.mu.Lock() + w.closed = true + w.mu.Unlock() +} + +func (w *WebviewWindow) Show() { + w.mu.Lock() + w.visible = true + w.mu.Unlock() +} + +func (w *WebviewWindow) Hide() { + w.mu.Lock() + w.visible = false + w.mu.Unlock() +} + +func (w *WebviewWindow) Fullscreen() { + w.mu.Lock() + w.fullscreen = true + w.mu.Unlock() +} + +func (w *WebviewWindow) UnFullscreen() { + w.mu.Lock() + w.fullscreen = false + w.mu.Unlock() +} + +func (w *WebviewWindow) OnWindowEvent(eventType events.WindowEventType, callback func(event *WindowEvent)) func() { + w.mu.Lock() + w.eventHandlers[eventType] = append(w.eventHandlers[eventType], callback) + w.mu.Unlock() + return func() {} +} + +// WindowManager manages in-memory windows. +type WindowManager struct { + mu sync.RWMutex + windows []*WebviewWindow +} + +func (wm *WindowManager) NewWithOptions(options WebviewWindowOptions) *WebviewWindow { + window := newWebviewWindow(options) + wm.mu.Lock() + wm.windows = append(wm.windows, window) + wm.mu.Unlock() + return window +} + +func (wm *WindowManager) GetAll() []any { + wm.mu.RLock() + defer wm.mu.RUnlock() + out := make([]any, 0, len(wm.windows)) + for _, window := range wm.windows { + out = append(out, window) + } + return out +} + +// App is the top-level application object used by the GUI packages. +type App struct { + Logger Logger + Window WindowManager + Menu MenuManager + SystemTray SystemTrayManager +} + +func (a *App) Quit() {} + +func (a *App) NewMenu() *Menu { + return NewMenu() +} diff --git a/stubs/wails/pkg/events/events.go b/stubs/wails/pkg/events/events.go new file mode 100644 index 0000000..3f3204d --- /dev/null +++ b/stubs/wails/pkg/events/events.go @@ -0,0 +1,30 @@ +package events + +// WindowEventType identifies a window event emitted by the application layer. +type WindowEventType int + +const ( + WindowFocus WindowEventType = iota + WindowLostFocus + WindowDidMove + WindowDidResize + WindowClosing + WindowFilesDropped +) + +// Common matches the event namespace used by the real Wails package. +var Common = struct { + WindowFocus WindowEventType + WindowLostFocus WindowEventType + WindowDidMove WindowEventType + WindowDidResize WindowEventType + WindowClosing WindowEventType + WindowFilesDropped WindowEventType +}{ + WindowFocus: WindowFocus, + WindowLostFocus: WindowLostFocus, + WindowDidMove: WindowDidMove, + WindowDidResize: WindowDidResize, + WindowClosing: WindowClosing, + WindowFilesDropped: WindowFilesDropped, +} -- 2.45.3