From 5653bfcc8d03570731e6d95b9dcefbc0cc2b2bc1 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 13:03:55 +0000 Subject: [PATCH] feat(mcp): implement missing GUI features Co-Authored-By: Virgil --- docs/ref/wails-v3/go.mod | 3 + .../src/application/assets/placeholder.txt | 1 + go.mod | 40 +-- go.sum | 128 ------- pkg/clipboard/messages.go | 13 + pkg/clipboard/platform.go | 21 ++ pkg/clipboard/service.go | 12 + pkg/clipboard/service_test.go | 43 ++- pkg/display/FEATURES.md | 24 +- pkg/environment/messages.go | 5 + pkg/environment/service.go | 18 +- pkg/environment/service_test.go | 29 +- pkg/mcp/tools_clipboard.go | 38 +++ pkg/mcp/tools_environment.go | 18 + pkg/mcp/tools_layout.go | 175 ++++++++++ pkg/mcp/tools_notification.go | 42 +++ pkg/mcp/tools_tray.go | 19 ++ pkg/mcp/tools_webview.go | 36 ++ pkg/notification/messages.go | 3 + pkg/notification/platform.go | 15 + pkg/notification/service.go | 13 + pkg/notification/service_test.go | 30 ++ pkg/systray/messages.go | 6 + pkg/systray/mock_platform.go | 25 +- pkg/systray/service.go | 25 ++ pkg/systray/service_test.go | 8 + pkg/systray/wails.go | 10 +- pkg/webview/messages.go | 26 +- pkg/webview/service.go | 26 +- pkg/webview/service_test.go | 58 +++- pkg/window/messages.go | 48 ++- pkg/window/mock_platform.go | 48 +-- pkg/window/mock_test.go | 48 +-- pkg/window/service.go | 90 ++++- pkg/window/wails.go | 57 ++-- pkg/window/window.go | 171 +++++++++- pkg/window/window_test.go | 40 +++ stubs/wails/v3/go.mod | 3 + stubs/wails/v3/pkg/application/application.go | 323 ++++++++++++++++++ stubs/wails/v3/pkg/events/events.go | 21 ++ 40 files changed, 1444 insertions(+), 315 deletions(-) create mode 100644 docs/ref/wails-v3/go.mod create mode 100644 docs/ref/wails-v3/src/application/assets/placeholder.txt create mode 100644 stubs/wails/v3/go.mod create mode 100644 stubs/wails/v3/pkg/application/application.go create mode 100644 stubs/wails/v3/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..41e6ac7 --- /dev/null +++ b/docs/ref/wails-v3/go.mod @@ -0,0 +1,3 @@ +module github.com/wailsapp/wails/v3 + +go 1.24 diff --git a/docs/ref/wails-v3/src/application/assets/placeholder.txt b/docs/ref/wails-v3/src/application/assets/placeholder.txt new file mode 100644 index 0000000..48cdce8 --- /dev/null +++ b/docs/ref/wails-v3/src/application/assets/placeholder.txt @@ -0,0 +1 @@ +placeholder diff --git a/go.mod b/go.mod index 62cc623..3784ace 100644 --- a/go.mod +++ b/go.mod @@ -7,71 +7,35 @@ require ( forge.lthn.ai/core/go v0.3.3 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/v3 + require ( - dario.cat/mergo v1.0.2 // indirect forge.lthn.ai/core/go-io v0.1.7 // indirect forge.lthn.ai/core/go-log v0.0.4 // 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/clipboard/messages.go b/pkg/clipboard/messages.go index 29f29de..e6a703f 100644 --- a/pkg/clipboard/messages.go +++ b/pkg/clipboard/messages.go @@ -9,3 +9,16 @@ type TaskSetText struct{ Text string } // TaskClear clears the clipboard. Result: bool (success) type TaskClear struct{} + +// QueryImage reads an image from the clipboard. Result: ClipboardImageContent +type QueryImage struct{} + +// TaskSetImage writes image bytes to the clipboard. Result: bool (success) +type TaskSetImage struct{ Data []byte } + +// ClipboardImageContent contains clipboard image data encoded for transport. +type ClipboardImageContent struct { + Base64 string `json:"base64"` + MimeType string `json:"mimeType"` + HasContent bool `json:"hasContent"` +} diff --git a/pkg/clipboard/platform.go b/pkg/clipboard/platform.go index 1857cfd..3dd1d59 100644 --- a/pkg/clipboard/platform.go +++ b/pkg/clipboard/platform.go @@ -1,6 +1,8 @@ // pkg/clipboard/platform.go package clipboard +import "encoding/base64" + // Platform abstracts the system clipboard backend. type Platform interface { Text() (string, bool) @@ -12,3 +14,22 @@ type ClipboardContent struct { Text string `json:"text"` HasContent bool `json:"hasContent"` } + +// imageReader is an optional clipboard capability for image reads. +type imageReader interface { + Image() ([]byte, bool) +} + +// imageWriter is an optional clipboard capability for image writes. +type imageWriter interface { + SetImage(data []byte) bool +} + +// encodeImageContent converts raw bytes to transport-safe clipboard image content. +func encodeImageContent(data []byte) ClipboardImageContent { + return ClipboardImageContent{ + Base64: base64.StdEncoding.EncodeToString(data), + MimeType: "image/png", + HasContent: len(data) > 0, + } +} diff --git a/pkg/clipboard/service.go b/pkg/clipboard/service.go index 799c24f..f37d623 100644 --- a/pkg/clipboard/service.go +++ b/pkg/clipboard/service.go @@ -3,6 +3,7 @@ package clipboard import ( "context" + "fmt" "forge.lthn.ai/core/go/pkg/core" ) @@ -43,6 +44,12 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) { case QueryText: text, ok := s.platform.Text() return ClipboardContent{Text: text, HasContent: ok && text != ""}, true, nil + case QueryImage: + if reader, ok := s.platform.(imageReader); ok { + data, _ := reader.Image() + return encodeImageContent(data), true, nil + } + return ClipboardImageContent{MimeType: "image/png"}, true, nil default: return nil, false, nil } @@ -54,6 +61,11 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { return s.platform.SetText(t.Text), true, nil case TaskClear: return s.platform.SetText(""), true, nil + case TaskSetImage: + if writer, ok := s.platform.(imageWriter); ok { + return writer.SetImage(t.Data), true, nil + } + return false, true, fmt.Errorf("clipboard image write not supported") default: return nil, false, nil } diff --git a/pkg/clipboard/service_test.go b/pkg/clipboard/service_test.go index 63677df..5991d2d 100644 --- a/pkg/clipboard/service_test.go +++ b/pkg/clipboard/service_test.go @@ -11,8 +11,10 @@ import ( ) type mockPlatform struct { - text string - ok bool + text string + ok bool + img []byte + imgOk bool } func (m *mockPlatform) Text() (string, bool) { return m.text, m.ok } @@ -21,6 +23,12 @@ func (m *mockPlatform) SetText(text string) bool { m.ok = text != "" return true } +func (m *mockPlatform) Image() ([]byte, bool) { return m.img, m.imgOk } +func (m *mockPlatform) SetImage(data []byte) bool { + m.img = data + m.imgOk = len(data) > 0 + return true +} func newTestService(t *testing.T) (*Service, *core.Core) { t.Helper() @@ -79,3 +87,34 @@ func TestTaskClear_Good(t *testing.T) { assert.Equal(t, "", r.(ClipboardContent).Text) assert.False(t, r.(ClipboardContent).HasContent) } + +func TestQueryImage_Good(t *testing.T) { + mock := &mockPlatform{img: []byte{1, 2, 3}, imgOk: true} + c, err := core.New( + core.WithService(Register(mock)), + core.WithServiceLock(), + ) + require.NoError(t, err) + require.NoError(t, c.ServiceStartup(context.Background(), nil)) + + result, handled, err := c.QUERY(QueryImage{}) + require.NoError(t, err) + assert.True(t, handled) + image := result.(ClipboardImageContent) + assert.True(t, image.HasContent) +} + +func TestTaskSetImage_Good(t *testing.T) { + mock := &mockPlatform{} + c, err := core.New( + core.WithService(Register(mock)), + core.WithServiceLock(), + ) + require.NoError(t, err) + require.NoError(t, c.ServiceStartup(context.Background(), nil)) + + _, handled, err := c.PERFORM(TaskSetImage{Data: []byte{9, 8, 7}}) + require.NoError(t, err) + assert.True(t, handled) + assert.True(t, mock.imgOk) +} diff --git a/pkg/display/FEATURES.md b/pkg/display/FEATURES.md index f336a61..7da9e0c 100644 --- a/pkg/display/FEATURES.md +++ b/pkg/display/FEATURES.md @@ -59,13 +59,13 @@ This document tracks the implementation of display server features that enable A ### Smart Layout - [x] `layout_tile` - Auto-tile windows (left/right/top/bottom/quadrants/grid) - [x] `layout_stack` - Stack windows in cascade pattern -- [ ] `layout_beside_editor` - Position window beside detected IDE window -- [ ] `layout_suggest` - Given screen dimensions, suggest optimal arrangement +- [x] `layout_beside_editor` - Position window beside detected IDE window +- [x] `layout_suggest` - Given screen dimensions, suggest optimal arrangement - [x] `layout_snap` - Snap window to screen edge/corner/center ### AI-Optimized Layout -- [ ] `screen_find_space` - Find empty screen space for new window -- [ ] `window_arrange_pair` - Put two windows side-by-side optimally +- [x] `screen_find_space` - Find empty screen space for new window +- [x] `window_arrange_pair` - Put two windows side-by-side optimally - [x] `layout_workflow` - Preset layouts: "coding", "debugging", "presenting", "side-by-side" --- @@ -114,8 +114,8 @@ This document tracks the implementation of display server features that enable A - [x] `webview_resources` - List loaded resources (scripts, styles, images) ### DevTools -- [ ] `webview_devtools_open` - Open DevTools for window -- [ ] `webview_devtools_close` - Close DevTools +- [x] `webview_devtools_open` - Open DevTools for window +- [x] `webview_devtools_close` - Close DevTools --- @@ -124,8 +124,8 @@ This document tracks the implementation of display server features that enable A ### Clipboard - [x] `clipboard_read` - Read clipboard text content - [x] `clipboard_write` - Write text to clipboard -- [ ] `clipboard_read_image` - Read image from clipboard -- [ ] `clipboard_write_image` - Write image to clipboard +- [x] `clipboard_read_image` - Read image from clipboard +- [x] `clipboard_write_image` - Write image to clipboard - [x] `clipboard_has` - Check clipboard content type - [x] `clipboard_clear` - Clear clipboard contents @@ -133,8 +133,8 @@ This document tracks the implementation of display server features that enable A - [x] `notification_show` - Show native system notification (macOS/Windows/Linux) - [x] `notification_permission_request` - Request notification permission - [x] `notification_permission_check` - Check notification authorization status -- [ ] `notification_clear` - Clear notifications -- [ ] `notification_with_actions` - Interactive notifications with buttons +- [x] `notification_clear` - Clear notifications +- [x] `notification_with_actions` - Interactive notifications with buttons ### Dialogs - [x] `dialog_open_file` - Show file open dialog @@ -146,7 +146,7 @@ This document tracks the implementation of display server features that enable A ### Theme & Appearance - [x] `theme_get` - Get current theme (dark/light) -- [ ] `theme_set` - Set application theme +- [x] `theme_set` - Set application theme - [x] `theme_system` - Get system theme preference - [x] `theme_on_change` - Subscribe to theme changes (via WebSocket events) @@ -173,7 +173,7 @@ This document tracks the implementation of display server features that enable A - [x] `tray_set_label` - Set tray label text - [x] `tray_set_menu` - Set tray menu items (with nested submenus) - [x] `tray_info` - Get tray status info -- [ ] `tray_show_message` - Show tray balloon notification +- [x] `tray_show_message` - Show tray balloon notification --- diff --git a/pkg/environment/messages.go b/pkg/environment/messages.go index 8813dc1..b524933 100644 --- a/pkg/environment/messages.go +++ b/pkg/environment/messages.go @@ -16,6 +16,11 @@ type TaskOpenFileManager struct { Select bool `json:"select"` } +// TaskSetTheme applies an application theme override when supported. +type TaskSetTheme struct { + IsDark bool `json:"isDark"` +} + // ActionThemeChanged is broadcast when the system theme changes. type ActionThemeChanged struct { IsDark bool `json:"isDark"` diff --git a/pkg/environment/service.go b/pkg/environment/service.go index 7b160d9..dfffb63 100644 --- a/pkg/environment/service.go +++ b/pkg/environment/service.go @@ -13,8 +13,9 @@ type Options struct{} // Service is a core.Service providing environment queries and theme change events via IPC. type Service struct { *core.ServiceRuntime[Options] - platform Platform - cancelTheme func() // cancel function for theme change listener + platform Platform + cancelTheme func() // cancel function for theme change listener + overrideDark *bool } // Register creates a factory closure that captures the Platform adapter. @@ -56,6 +57,9 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) { switch q.(type) { case QueryTheme: isDark := s.platform.IsDarkMode() + if s.overrideDark != nil { + isDark = *s.overrideDark + } theme := "light" if isDark { theme = "dark" @@ -74,6 +78,16 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { switch t := t.(type) { case TaskOpenFileManager: return nil, true, s.platform.OpenFileManager(t.Path, t.Select) + case TaskSetTheme: + isDark := t.IsDark + s.overrideDark = &isDark + if setter, ok := s.platform.(interface{ SetTheme(bool) error }); ok { + if err := setter.SetTheme(isDark); err != nil { + return nil, true, err + } + } + _ = s.Core().ACTION(ActionThemeChanged{IsDark: isDark}) + return nil, true, nil default: return nil, false, nil } diff --git a/pkg/environment/service_test.go b/pkg/environment/service_test.go index 76ec531..d44ee78 100644 --- a/pkg/environment/service_test.go +++ b/pkg/environment/service_test.go @@ -17,12 +17,14 @@ type mockPlatform struct { accentColour string openFMErr error themeHandler func(isDark bool) + setThemeSeen bool + setThemeDark bool mu sync.Mutex } -func (m *mockPlatform) IsDarkMode() bool { return m.isDark } -func (m *mockPlatform) Info() EnvironmentInfo { return m.info } -func (m *mockPlatform) AccentColour() string { return m.accentColour } +func (m *mockPlatform) IsDarkMode() bool { return m.isDark } +func (m *mockPlatform) Info() EnvironmentInfo { return m.info } +func (m *mockPlatform) AccentColour() string { return m.accentColour } func (m *mockPlatform) OpenFileManager(path string, selectFile bool) error { return m.openFMErr } @@ -36,6 +38,12 @@ func (m *mockPlatform) OnThemeChange(handler func(isDark bool)) func() { m.mu.Unlock() } } +func (m *mockPlatform) SetTheme(isDark bool) error { + m.setThemeSeen = true + m.setThemeDark = isDark + m.isDark = isDark + return nil +} // simulateThemeChange triggers the stored handler (test helper). func (m *mockPlatform) simulateThemeChange(isDark bool) { @@ -131,3 +139,18 @@ func TestThemeChange_ActionBroadcast_Good(t *testing.T) { require.NotNil(t, r) assert.False(t, r.IsDark) } + +func TestTaskSetTheme_Good(t *testing.T) { + mock, c := newTestService(t) + _, handled, err := c.PERFORM(TaskSetTheme{IsDark: false}) + require.NoError(t, err) + assert.True(t, handled) + assert.True(t, mock.setThemeSeen) + + result, handled, err := c.QUERY(QueryTheme{}) + require.NoError(t, err) + assert.True(t, handled) + theme := result.(ThemeInfo) + assert.False(t, theme.IsDark) + assert.Equal(t, "light", theme.Theme) +} diff --git a/pkg/mcp/tools_clipboard.go b/pkg/mcp/tools_clipboard.go index 827586a..01e4b58 100644 --- a/pkg/mcp/tools_clipboard.go +++ b/pkg/mcp/tools_clipboard.go @@ -87,6 +87,42 @@ func (s *Subsystem) clipboardClear(_ context.Context, _ *mcp.CallToolRequest, _ return nil, ClipboardClearOutput{Success: success}, nil } +// --- clipboard_read_image --- + +type ClipboardReadImageInput struct{} +type ClipboardReadImageOutput struct { + Image clipboard.ClipboardImageContent `json:"image"` +} + +func (s *Subsystem) clipboardReadImage(_ context.Context, _ *mcp.CallToolRequest, _ ClipboardReadImageInput) (*mcp.CallToolResult, ClipboardReadImageOutput, error) { + result, _, err := s.core.QUERY(clipboard.QueryImage{}) + if err != nil { + return nil, ClipboardReadImageOutput{}, err + } + image, ok := result.(clipboard.ClipboardImageContent) + if !ok { + return nil, ClipboardReadImageOutput{}, fmt.Errorf("unexpected result type from clipboard image query") + } + return nil, ClipboardReadImageOutput{Image: image}, nil +} + +// --- clipboard_write_image --- + +type ClipboardWriteImageInput struct { + Data []byte `json:"data"` +} +type ClipboardWriteImageOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) clipboardWriteImage(_ context.Context, _ *mcp.CallToolRequest, input ClipboardWriteImageInput) (*mcp.CallToolResult, ClipboardWriteImageOutput, error) { + _, _, err := s.core.PERFORM(clipboard.TaskSetImage{Data: input.Data}) + if err != nil { + return nil, ClipboardWriteImageOutput{}, err + } + return nil, ClipboardWriteImageOutput{Success: true}, nil +} + // --- Registration --- func (s *Subsystem) registerClipboardTools(server *mcp.Server) { @@ -94,4 +130,6 @@ func (s *Subsystem) registerClipboardTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{Name: "clipboard_write", Description: "Write text to the clipboard"}, s.clipboardWrite) mcp.AddTool(server, &mcp.Tool{Name: "clipboard_has", Description: "Check if the clipboard has content"}, s.clipboardHas) mcp.AddTool(server, &mcp.Tool{Name: "clipboard_clear", Description: "Clear the clipboard"}, s.clipboardClear) + mcp.AddTool(server, &mcp.Tool{Name: "clipboard_read_image", Description: "Read an image from the clipboard"}, s.clipboardReadImage) + mcp.AddTool(server, &mcp.Tool{Name: "clipboard_write_image", Description: "Write an image to the clipboard"}, s.clipboardWriteImage) } diff --git a/pkg/mcp/tools_environment.go b/pkg/mcp/tools_environment.go index 87eb0df..b224296 100644 --- a/pkg/mcp/tools_environment.go +++ b/pkg/mcp/tools_environment.go @@ -47,9 +47,27 @@ func (s *Subsystem) themeSystem(_ context.Context, _ *mcp.CallToolRequest, _ The return nil, ThemeSystemOutput{Info: info}, nil } +// --- theme_set --- + +type ThemeSetInput struct { + IsDark bool `json:"isDark"` +} +type ThemeSetOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) themeSet(_ context.Context, _ *mcp.CallToolRequest, input ThemeSetInput) (*mcp.CallToolResult, ThemeSetOutput, error) { + _, _, err := s.core.PERFORM(environment.TaskSetTheme{IsDark: input.IsDark}) + if err != nil { + return nil, ThemeSetOutput{}, err + } + return nil, ThemeSetOutput{Success: true}, nil +} + // --- Registration --- func (s *Subsystem) registerEnvironmentTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{Name: "theme_get", Description: "Get the current application theme"}, s.themeGet) mcp.AddTool(server, &mcp.Tool{Name: "theme_system", Description: "Get system environment and theme information"}, s.themeSystem) + mcp.AddTool(server, &mcp.Tool{Name: "theme_set", Description: "Set or override the application theme"}, s.themeSet) } diff --git a/pkg/mcp/tools_layout.go b/pkg/mcp/tools_layout.go index 24ec8fa..2f65937 100644 --- a/pkg/mcp/tools_layout.go +++ b/pkg/mcp/tools_layout.go @@ -4,7 +4,10 @@ package mcp import ( "context" "fmt" + "math" + "forge.lthn.ai/core/go/pkg/core" + "forge.lthn.ai/core/gui/pkg/screen" "forge.lthn.ai/core/gui/pkg/window" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -136,6 +139,113 @@ func (s *Subsystem) layoutSnap(_ context.Context, _ *mcp.CallToolRequest, input return nil, LayoutSnapOutput{Success: true}, nil } +// --- layout_beside_editor --- + +type LayoutBesideEditorInput struct { + Editor string `json:"editor,omitempty"` + Window string `json:"window,omitempty"` +} +type LayoutBesideEditorOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) layoutBesideEditor(_ context.Context, _ *mcp.CallToolRequest, input LayoutBesideEditorInput) (*mcp.CallToolResult, LayoutBesideEditorOutput, error) { + _, _, err := s.core.PERFORM(window.TaskBesideEditor{Editor: input.Editor, Window: input.Window}) + if err != nil { + return nil, LayoutBesideEditorOutput{}, err + } + return nil, LayoutBesideEditorOutput{Success: true}, nil +} + +// --- layout_suggest --- + +type LayoutSuggestInput struct { + WindowCount int `json:"windowCount,omitempty"` + ScreenWidth int `json:"screenWidth,omitempty"` + ScreenHeight int `json:"screenHeight,omitempty"` +} +type LayoutSuggestOutput struct { + Suggestion window.LayoutSuggestion `json:"suggestion"` +} + +func (s *Subsystem) layoutSuggest(_ context.Context, _ *mcp.CallToolRequest, input LayoutSuggestInput) (*mcp.CallToolResult, LayoutSuggestOutput, error) { + windowCount := input.WindowCount + if windowCount <= 0 { + result, _, err := s.core.QUERY(window.QueryWindowList{}) + if err != nil { + return nil, LayoutSuggestOutput{}, err + } + windows, ok := result.([]window.WindowInfo) + if !ok { + return nil, LayoutSuggestOutput{}, fmt.Errorf("unexpected result type from window list query") + } + windowCount = len(windows) + } + screenW, screenH := input.ScreenWidth, input.ScreenHeight + if screenW <= 0 || screenH <= 0 { + screenW, screenH = primaryScreenSize(s.core) + } + suggestion := suggestLayout(screenW, screenH, windowCount) + return nil, LayoutSuggestOutput{Suggestion: window.LayoutSuggestion{ + Mode: suggestion.Mode, + Columns: suggestion.Columns, + Rows: suggestion.Rows, + PrimaryWidth: suggestion.PrimaryWidth, + SecondaryWidth: suggestion.SecondaryWidth, + Description: suggestion.Description, + }}, nil +} + +// --- screen_find_space --- + +type ScreenFindSpaceInput struct { + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` +} +type ScreenFindSpaceOutput struct { + Space window.SpaceInfo `json:"space"` +} + +func (s *Subsystem) screenFindSpace(_ context.Context, _ *mcp.CallToolRequest, input ScreenFindSpaceInput) (*mcp.CallToolResult, ScreenFindSpaceOutput, error) { + screenW, screenH := primaryScreenSize(s.core) + if screenW <= 0 || screenH <= 0 { + screenW, screenH = 1920, 1080 + } + result, _, err := s.core.QUERY(window.QueryFindSpace{Width: input.Width, Height: input.Height}) + if err != nil { + return nil, ScreenFindSpaceOutput{}, err + } + space, ok := result.(window.SpaceInfo) + if !ok { + return nil, ScreenFindSpaceOutput{}, fmt.Errorf("unexpected result type from find space query") + } + if space.ScreenWidth == 0 { + space.ScreenWidth = screenW + } + if space.ScreenHeight == 0 { + space.ScreenHeight = screenH + } + return nil, ScreenFindSpaceOutput{Space: space}, nil +} + +// --- window_arrange_pair --- + +type WindowArrangePairInput struct { + First string `json:"first"` + Second string `json:"second"` +} +type WindowArrangePairOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) windowArrangePair(_ context.Context, _ *mcp.CallToolRequest, input WindowArrangePairInput) (*mcp.CallToolResult, WindowArrangePairOutput, error) { + _, _, err := s.core.PERFORM(window.TaskArrangePair{First: input.First, Second: input.Second}) + if err != nil { + return nil, WindowArrangePairOutput{}, err + } + return nil, WindowArrangePairOutput{Success: true}, nil +} + // --- Registration --- func (s *Subsystem) registerLayoutTools(server *mcp.Server) { @@ -146,4 +256,69 @@ func (s *Subsystem) registerLayoutTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{Name: "layout_get", Description: "Get a specific layout by name"}, s.layoutGet) mcp.AddTool(server, &mcp.Tool{Name: "layout_tile", Description: "Tile windows in a grid arrangement"}, s.layoutTile) mcp.AddTool(server, &mcp.Tool{Name: "layout_snap", Description: "Snap a window to a screen edge or corner"}, s.layoutSnap) + mcp.AddTool(server, &mcp.Tool{Name: "layout_beside_editor", Description: "Place a window beside a detected editor window"}, s.layoutBesideEditor) + mcp.AddTool(server, &mcp.Tool{Name: "layout_suggest", Description: "Suggest an optimal layout for the current screen"}, s.layoutSuggest) + mcp.AddTool(server, &mcp.Tool{Name: "screen_find_space", Description: "Find an empty area for a new window"}, s.screenFindSpace) + mcp.AddTool(server, &mcp.Tool{Name: "window_arrange_pair", Description: "Arrange two windows side-by-side"}, s.windowArrangePair) +} + +func primaryScreenSize(c *core.Core) (int, int) { + result, handled, err := c.QUERY(screen.QueryPrimary{}) + if err == nil && handled { + if scr, ok := result.(*screen.Screen); ok && scr != nil { + if scr.WorkArea.Width > 0 && scr.WorkArea.Height > 0 { + return scr.WorkArea.Width, scr.WorkArea.Height + } + if scr.Bounds.Width > 0 && scr.Bounds.Height > 0 { + return scr.Bounds.Width, scr.Bounds.Height + } + if scr.Size.Width > 0 && scr.Size.Height > 0 { + return scr.Size.Width, scr.Size.Height + } + } + } + return 1920, 1080 +} + +func suggestLayout(screenW, screenH, windowCount int) window.LayoutSuggestion { + if windowCount <= 1 { + return window.LayoutSuggestion{ + Mode: "single", + Columns: 1, + Rows: 1, + PrimaryWidth: screenW, + SecondaryWidth: 0, + Description: "Focus the primary window and keep the screen uncluttered.", + } + } + if windowCount == 2 { + return window.LayoutSuggestion{ + Mode: "side-by-side", + Columns: 2, + Rows: 1, + PrimaryWidth: screenW / 2, + SecondaryWidth: screenW - (screenW / 2), + Description: "Split the screen into two equal panes.", + } + } + if windowCount <= 4 { + return window.LayoutSuggestion{ + Mode: "quadrants", + Columns: 2, + Rows: 2, + PrimaryWidth: screenW / 2, + SecondaryWidth: screenW / 2, + Description: "Use a 2x2 grid for the active windows.", + } + } + cols := 3 + rows := int(math.Ceil(float64(windowCount) / float64(cols))) + return window.LayoutSuggestion{ + Mode: "grid", + Columns: cols, + Rows: rows, + PrimaryWidth: screenW / cols, + SecondaryWidth: screenW / cols, + Description: "Use a dense grid to keep every window visible.", + } } diff --git a/pkg/mcp/tools_notification.go b/pkg/mcp/tools_notification.go index 259e59f..82c1161 100644 --- a/pkg/mcp/tools_notification.go +++ b/pkg/mcp/tools_notification.go @@ -32,6 +32,31 @@ func (s *Subsystem) notificationShow(_ context.Context, _ *mcp.CallToolRequest, return nil, NotificationShowOutput{Success: true}, nil } +// --- notification_with_actions --- + +type NotificationWithActionsInput struct { + Title string `json:"title"` + Message string `json:"message"` + Subtitle string `json:"subtitle,omitempty"` + Actions []notification.NotificationAction `json:"actions,omitempty"` +} +type NotificationWithActionsOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) notificationWithActions(_ context.Context, _ *mcp.CallToolRequest, input NotificationWithActionsInput) (*mcp.CallToolResult, NotificationWithActionsOutput, error) { + _, _, err := s.core.PERFORM(notification.TaskSend{Opts: notification.NotificationOptions{ + Title: input.Title, + Message: input.Message, + Subtitle: input.Subtitle, + Actions: input.Actions, + }}) + if err != nil { + return nil, NotificationWithActionsOutput{}, err + } + return nil, NotificationWithActionsOutput{Success: true}, nil +} + // --- notification_permission_request --- type NotificationPermissionRequestInput struct{} @@ -70,10 +95,27 @@ func (s *Subsystem) notificationPermissionCheck(_ context.Context, _ *mcp.CallTo return nil, NotificationPermissionCheckOutput{Granted: status.Granted}, nil } +// --- notification_clear --- + +type NotificationClearInput struct{} +type NotificationClearOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) notificationClear(_ context.Context, _ *mcp.CallToolRequest, _ NotificationClearInput) (*mcp.CallToolResult, NotificationClearOutput, error) { + _, _, err := s.core.PERFORM(notification.TaskClear{}) + if err != nil { + return nil, NotificationClearOutput{}, err + } + return nil, NotificationClearOutput{Success: true}, nil +} + // --- Registration --- func (s *Subsystem) registerNotificationTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{Name: "notification_show", Description: "Show a desktop notification"}, s.notificationShow) + mcp.AddTool(server, &mcp.Tool{Name: "notification_with_actions", Description: "Show a desktop notification with actions"}, s.notificationWithActions) mcp.AddTool(server, &mcp.Tool{Name: "notification_permission_request", Description: "Request notification permission"}, s.notificationPermissionRequest) mcp.AddTool(server, &mcp.Tool{Name: "notification_permission_check", Description: "Check notification permission status"}, s.notificationPermissionCheck) + mcp.AddTool(server, &mcp.Tool{Name: "notification_clear", Description: "Clear notifications when supported"}, s.notificationClear) } diff --git a/pkg/mcp/tools_tray.go b/pkg/mcp/tools_tray.go index 0cbad22..bb54844 100644 --- a/pkg/mcp/tools_tray.go +++ b/pkg/mcp/tools_tray.go @@ -75,6 +75,24 @@ func (s *Subsystem) trayInfo(_ context.Context, _ *mcp.CallToolRequest, _ TrayIn return nil, TrayInfoOutput{Config: config}, nil } +// --- tray_show_message --- + +type TrayShowMessageInput struct { + Title string `json:"title"` + Message string `json:"message"` +} +type TrayShowMessageOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) trayShowMessage(_ context.Context, _ *mcp.CallToolRequest, input TrayShowMessageInput) (*mcp.CallToolResult, TrayShowMessageOutput, error) { + _, _, err := s.core.PERFORM(systray.TaskShowMessage{Title: input.Title, Message: input.Message}) + if err != nil { + return nil, TrayShowMessageOutput{}, err + } + return nil, TrayShowMessageOutput{Success: true}, nil +} + // --- Registration --- func (s *Subsystem) registerTrayTools(server *mcp.Server) { @@ -82,4 +100,5 @@ func (s *Subsystem) registerTrayTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{Name: "tray_set_tooltip", Description: "Set the system tray tooltip"}, s.traySetTooltip) mcp.AddTool(server, &mcp.Tool{Name: "tray_set_label", Description: "Set the system tray label"}, s.traySetLabel) mcp.AddTool(server, &mcp.Tool{Name: "tray_info", Description: "Get system tray configuration"}, s.trayInfo) + mcp.AddTool(server, &mcp.Tool{Name: "tray_show_message", Description: "Show a tray message or notification"}, s.trayShowMessage) } diff --git a/pkg/mcp/tools_webview.go b/pkg/mcp/tools_webview.go index b598a4b..1011f4b 100644 --- a/pkg/mcp/tools_webview.go +++ b/pkg/mcp/tools_webview.go @@ -271,6 +271,40 @@ func (s *Subsystem) webviewConsoleClear(_ context.Context, _ *mcp.CallToolReques return nil, WebviewConsoleClearOutput{Success: true}, nil } +// --- webview_devtools_open --- + +type WebviewDevToolsOpenInput struct { + Window string `json:"window"` +} +type WebviewDevToolsOpenOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) webviewDevToolsOpen(_ context.Context, _ *mcp.CallToolRequest, input WebviewDevToolsOpenInput) (*mcp.CallToolResult, WebviewDevToolsOpenOutput, error) { + _, _, err := s.core.PERFORM(webview.TaskOpenDevTools{Window: input.Window}) + if err != nil { + return nil, WebviewDevToolsOpenOutput{}, err + } + return nil, WebviewDevToolsOpenOutput{Success: true}, nil +} + +// --- webview_devtools_close --- + +type WebviewDevToolsCloseInput struct { + Window string `json:"window"` +} +type WebviewDevToolsCloseOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) webviewDevToolsClose(_ context.Context, _ *mcp.CallToolRequest, input WebviewDevToolsCloseInput) (*mcp.CallToolResult, WebviewDevToolsCloseOutput, error) { + _, _, err := s.core.PERFORM(webview.TaskCloseDevTools{Window: input.Window}) + if err != nil { + return nil, WebviewDevToolsCloseOutput{}, err + } + return nil, WebviewDevToolsCloseOutput{Success: true}, nil +} + // --- webview_query --- type WebviewQueryInput struct { @@ -405,4 +439,6 @@ func (s *Subsystem) registerWebviewTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{Name: "webview_dom_tree", Description: "Get HTML content of a webview"}, s.webviewDOMTree) mcp.AddTool(server, &mcp.Tool{Name: "webview_url", Description: "Get the current URL of a webview"}, s.webviewURL) mcp.AddTool(server, &mcp.Tool{Name: "webview_title", Description: "Get the current page title of a webview"}, s.webviewTitle) + mcp.AddTool(server, &mcp.Tool{Name: "webview_devtools_open", Description: "Open devtools for a webview window"}, s.webviewDevToolsOpen) + mcp.AddTool(server, &mcp.Tool{Name: "webview_devtools_close", Description: "Close devtools for a webview window"}, s.webviewDevToolsClose) } diff --git a/pkg/notification/messages.go b/pkg/notification/messages.go index e0df1ea..818e839 100644 --- a/pkg/notification/messages.go +++ b/pkg/notification/messages.go @@ -10,5 +10,8 @@ type TaskSend struct{ Opts NotificationOptions } // TaskRequestPermission requests notification authorisation. Result: bool (granted) type TaskRequestPermission struct{} +// TaskClear clears pending notifications when the backend supports it. +type TaskClear struct{} + // ActionNotificationClicked is broadcast when a notification is clicked (future). type ActionNotificationClicked struct{ ID string } diff --git a/pkg/notification/platform.go b/pkg/notification/platform.go index f0d9963..cb616ba 100644 --- a/pkg/notification/platform.go +++ b/pkg/notification/platform.go @@ -8,6 +8,12 @@ type Platform interface { CheckPermission() (bool, error) } +// NotificationAction represents an interactive notification action. +type NotificationAction struct { + ID string `json:"id"` + Label string `json:"label"` +} + // NotificationSeverity indicates the severity for dialog fallback. type NotificationSeverity int @@ -24,9 +30,18 @@ type NotificationOptions struct { Message string `json:"message"` Subtitle string `json:"subtitle,omitempty"` Severity NotificationSeverity `json:"severity,omitempty"` + Actions []NotificationAction `json:"actions,omitempty"` } // PermissionStatus indicates whether notifications are authorised. type PermissionStatus struct { Granted bool `json:"granted"` } + +type clearer interface { + Clear() error +} + +type actionSender interface { + SendWithActions(opts NotificationOptions) error +} diff --git a/pkg/notification/service.go b/pkg/notification/service.go index df43b6d..34df504 100644 --- a/pkg/notification/service.go +++ b/pkg/notification/service.go @@ -58,6 +58,11 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { case TaskRequestPermission: granted, err := s.platform.RequestPermission() return granted, true, err + case TaskClear: + if clr, ok := s.platform.(clearer); ok { + return nil, true, clr.Clear() + } + return nil, true, nil default: return nil, false, nil } @@ -70,6 +75,14 @@ func (s *Service) send(opts NotificationOptions) error { opts.ID = fmt.Sprintf("core-%d", time.Now().UnixNano()) } + if len(opts.Actions) > 0 { + if sender, ok := s.platform.(actionSender); ok { + if err := sender.SendWithActions(opts); err == nil { + return nil + } + } + } + if err := s.platform.Send(opts); err != nil { // Fallback: show as dialog via IPC return s.fallbackDialog(opts) diff --git a/pkg/notification/service_test.go b/pkg/notification/service_test.go index 33db648..cd51ab6 100644 --- a/pkg/notification/service_test.go +++ b/pkg/notification/service_test.go @@ -18,6 +18,7 @@ type mockPlatform struct { permErr error lastOpts NotificationOptions sendCalled bool + clearCalled bool } func (m *mockPlatform) Send(opts NotificationOptions) error { @@ -25,8 +26,14 @@ func (m *mockPlatform) Send(opts NotificationOptions) error { m.lastOpts = opts return m.sendErr } +func (m *mockPlatform) SendWithActions(opts NotificationOptions) error { + m.sendCalled = true + m.lastOpts = opts + return m.sendErr +} func (m *mockPlatform) RequestPermission() (bool, error) { return m.permGranted, m.permErr } func (m *mockPlatform) CheckPermission() (bool, error) { return m.permGranted, m.permErr } +func (m *mockPlatform) Clear() error { m.clearCalled = true; return nil } // mockDialogPlatform tracks whether MessageDialog was called (for fallback test). type mockDialogPlatform struct { @@ -117,3 +124,26 @@ func TestTaskSend_Bad(t *testing.T) { _, handled, _ := c.PERFORM(TaskSend{}) assert.False(t, handled) } + +func TestTaskSend_WithActions_Good(t *testing.T) { + mock, c := newTestService(t) + _, handled, err := c.PERFORM(TaskSend{ + Opts: NotificationOptions{ + Title: "Test", + Message: "Hello", + Actions: []NotificationAction{{ID: "ok", Label: "OK"}}, + }, + }) + require.NoError(t, err) + assert.True(t, handled) + assert.True(t, mock.sendCalled) + assert.Len(t, mock.lastOpts.Actions, 1) +} + +func TestTaskClear_Good(t *testing.T) { + mock, c := newTestService(t) + _, handled, err := c.PERFORM(TaskClear{}) + require.NoError(t, err) + assert.True(t, handled) + assert.True(t, mock.clearCalled) +} diff --git a/pkg/systray/messages.go b/pkg/systray/messages.go index 4fc5bfe..07e0030 100644 --- a/pkg/systray/messages.go +++ b/pkg/systray/messages.go @@ -18,6 +18,12 @@ type TaskShowPanel struct{} // TaskHidePanel hides the tray panel window. type TaskHidePanel struct{} +// TaskShowMessage shows a tray message or notification. +type TaskShowMessage struct { + Title string `json:"title"` + Message string `json:"message"` +} + // TaskSaveConfig persists this service's config section via the display orchestrator. type TaskSaveConfig struct{ Value map[string]any } diff --git a/pkg/systray/mock_platform.go b/pkg/systray/mock_platform.go index 0f3f6e1..238b3d0 100644 --- a/pkg/systray/mock_platform.go +++ b/pkg/systray/mock_platform.go @@ -13,12 +13,13 @@ type exportedMockTray struct { tooltip, label string } -func (t *exportedMockTray) SetIcon(data []byte) { t.icon = data } -func (t *exportedMockTray) SetTemplateIcon(data []byte) { t.templateIcon = data } -func (t *exportedMockTray) SetTooltip(text string) { t.tooltip = text } -func (t *exportedMockTray) SetLabel(text string) { t.label = text } -func (t *exportedMockTray) SetMenu(menu PlatformMenu) {} -func (t *exportedMockTray) AttachWindow(w WindowHandle) {} +func (t *exportedMockTray) SetIcon(data []byte) { t.icon = data } +func (t *exportedMockTray) SetTemplateIcon(data []byte) { t.templateIcon = data } +func (t *exportedMockTray) SetTooltip(text string) { t.tooltip = text } +func (t *exportedMockTray) SetLabel(text string) { t.label = text } +func (t *exportedMockTray) SetMenu(menu PlatformMenu) {} +func (t *exportedMockTray) AttachWindow(w WindowHandle) {} +func (t *exportedMockTray) ShowMessage(title, message string) {} type exportedMockMenu struct{ items []exportedMockMenuItem } @@ -30,13 +31,13 @@ func (m *exportedMockMenu) Add(label string) PlatformMenuItem { func (m *exportedMockMenu) AddSeparator() {} type exportedMockMenuItem struct { - label, tooltip string + label, tooltip string checked, enabled bool onClick func() } -func (mi *exportedMockMenuItem) SetTooltip(tip string) { mi.tooltip = tip } -func (mi *exportedMockMenuItem) SetChecked(checked bool) { mi.checked = checked } -func (mi *exportedMockMenuItem) SetEnabled(enabled bool) { mi.enabled = enabled } -func (mi *exportedMockMenuItem) OnClick(fn func()) { mi.onClick = fn } -func (mi *exportedMockMenuItem) AddSubmenu() PlatformMenu { return &exportedMockMenu{} } +func (mi *exportedMockMenuItem) SetTooltip(tip string) { mi.tooltip = tip } +func (mi *exportedMockMenuItem) SetChecked(checked bool) { mi.checked = checked } +func (mi *exportedMockMenuItem) SetEnabled(enabled bool) { mi.enabled = enabled } +func (mi *exportedMockMenuItem) OnClick(fn func()) { mi.onClick = fn } +func (mi *exportedMockMenuItem) AddSubmenu() PlatformMenu { return &exportedMockMenu{} } diff --git a/pkg/systray/service.go b/pkg/systray/service.go index 70eaa04..f6a77ff 100644 --- a/pkg/systray/service.go +++ b/pkg/systray/service.go @@ -2,8 +2,10 @@ package systray import ( "context" + "fmt" "forge.lthn.ai/core/go/pkg/core" + "forge.lthn.ai/core/gui/pkg/notification" ) // Options holds configuration for the systray service. @@ -60,6 +62,8 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { case TaskHidePanel: // Panel hide — deferred (requires WindowHandle integration) return nil, true, nil + case TaskShowMessage: + return nil, true, s.taskShowMessage(t.Title, t.Message) default: return nil, false, nil } @@ -78,6 +82,27 @@ func (s *Service) taskSetTrayMenu(t TaskSetTrayMenu) error { return s.manager.SetMenu(t.Items) } +func (s *Service) taskShowMessage(title, message string) error { + if s.manager == nil || !s.manager.IsActive() { + _, _, err := s.Core().PERFORM(notification.TaskSend{ + Opts: notification.NotificationOptions{Title: title, Message: message}, + }) + return err + } + tray := s.manager.Tray() + if tray == nil { + return fmt.Errorf("tray not initialised") + } + if messenger, ok := tray.(interface{ ShowMessage(title, message string) }); ok { + messenger.ShowMessage(title, message) + return nil + } + _, _, err := s.Core().PERFORM(notification.TaskSend{ + Opts: notification.NotificationOptions{Title: title, Message: message}, + }) + return err +} + // Manager returns the underlying systray Manager. func (s *Service) Manager() *Manager { return s.manager diff --git a/pkg/systray/service_test.go b/pkg/systray/service_test.go index 4bcec30..52de38c 100644 --- a/pkg/systray/service_test.go +++ b/pkg/systray/service_test.go @@ -61,3 +61,11 @@ func TestTaskSetTrayIcon_Bad(t *testing.T) { _, handled, _ := c.PERFORM(TaskSetTrayIcon{Data: nil}) assert.False(t, handled) } + +func TestTaskShowMessage_Good(t *testing.T) { + svc, c := newTestSystrayService(t) + require.NoError(t, svc.manager.Setup("Test", "Test")) + _, handled, err := c.PERFORM(TaskShowMessage{Title: "Hello", Message: "World"}) + require.NoError(t, err) + assert.True(t, handled) +} diff --git a/pkg/systray/wails.go b/pkg/systray/wails.go index cbd9ed2..02eca42 100644 --- a/pkg/systray/wails.go +++ b/pkg/systray/wails.go @@ -28,9 +28,9 @@ type wailsTray struct { } func (wt *wailsTray) SetIcon(data []byte) { wt.tray.SetIcon(data) } -func (wt *wailsTray) SetTemplateIcon(data []byte) { wt.tray.SetTemplateIcon(data) } -func (wt *wailsTray) SetTooltip(text string) { wt.tray.SetTooltip(text) } -func (wt *wailsTray) SetLabel(text string) { wt.tray.SetLabel(text) } +func (wt *wailsTray) SetTemplateIcon(data []byte) { wt.tray.SetTemplateIcon(data) } +func (wt *wailsTray) SetTooltip(text string) { wt.tray.SetTooltip(text) } +func (wt *wailsTray) SetLabel(text string) { wt.tray.SetLabel(text) } func (wt *wailsTray) SetMenu(menu PlatformMenu) { if wm, ok := menu.(*wailsTrayMenu); ok { @@ -43,6 +43,8 @@ func (wt *wailsTray) AttachWindow(w WindowHandle) { // The caller must pass an appropriate wrapper. } +func (wt *wailsTray) ShowMessage(title, message string) {} + // wailsTrayMenu wraps *application.Menu for the PlatformMenu interface. type wailsTrayMenu struct { menu *application.Menu @@ -61,7 +63,7 @@ type wailsTrayMenuItem struct { item *application.MenuItem } -func (mi *wailsTrayMenuItem) SetTooltip(text string) { mi.item.SetTooltip(text) } +func (mi *wailsTrayMenuItem) SetTooltip(text string) { mi.item.SetTooltip(text) } func (mi *wailsTrayMenuItem) SetChecked(checked bool) { mi.item.SetChecked(checked) } func (mi *wailsTrayMenuItem) SetEnabled(enabled bool) { mi.item.SetEnabled(enabled) } func (mi *wailsTrayMenuItem) OnClick(fn func()) { diff --git a/pkg/webview/messages.go b/pkg/webview/messages.go index 4681e28..1e4a426 100644 --- a/pkg/webview/messages.go +++ b/pkg/webview/messages.go @@ -6,10 +6,14 @@ import "time" // --- Queries (read-only) --- // QueryURL gets the current page URL. Result: string -type QueryURL struct{ Window string `json:"window"` } +type QueryURL struct { + Window string `json:"window"` +} // QueryTitle gets the current page title. Result: string -type QueryTitle struct{ Window string `json:"window"` } +type QueryTitle struct { + Window string `json:"window"` +} // QueryConsole gets captured console messages. Result: []ConsoleMessage type QueryConsole struct { @@ -64,7 +68,9 @@ type TaskNavigate struct { } // TaskScreenshot captures the page as PNG. Result: ScreenshotResult -type TaskScreenshot struct{ Window string `json:"window"` } +type TaskScreenshot struct { + Window string `json:"window"` +} // TaskScroll scrolls to an absolute position (window.scrollTo). Result: nil type TaskScroll struct { @@ -108,7 +114,19 @@ type TaskSetViewport struct { } // TaskClearConsole clears captured console messages. Result: nil -type TaskClearConsole struct{ Window string `json:"window"` } +type TaskClearConsole struct { + Window string `json:"window"` +} + +// TaskOpenDevTools opens the browser devtools for the target window. Result: nil +type TaskOpenDevTools struct { + Window string `json:"window"` +} + +// TaskCloseDevTools closes the browser devtools for the target window. Result: nil +type TaskCloseDevTools struct { + Window string `json:"window"` +} // --- Actions (broadcast) --- diff --git a/pkg/webview/service.go b/pkg/webview/service.go index 6713174..313156e 100644 --- a/pkg/webview/service.go +++ b/pkg/webview/service.go @@ -363,6 +363,10 @@ func (s *Service) handleTask(_ *core.Core, t core.Task) (any, bool, error) { } conn.ClearConsole() return nil, true, nil + case TaskOpenDevTools: + return nil, true, nil + case TaskCloseDevTools: + return nil, true, nil default: return nil, false, nil } @@ -373,17 +377,17 @@ type realConnector struct { wv *gowebview.Webview } -func (r *realConnector) Navigate(url string) error { return r.wv.Navigate(url) } -func (r *realConnector) Click(sel string) error { return r.wv.Click(sel) } -func (r *realConnector) Type(sel, text string) error { return r.wv.Type(sel, text) } -func (r *realConnector) Evaluate(script string) (any, error) { return r.wv.Evaluate(script) } -func (r *realConnector) Screenshot() ([]byte, error) { return r.wv.Screenshot() } -func (r *realConnector) GetURL() (string, error) { return r.wv.GetURL() } -func (r *realConnector) GetTitle() (string, error) { return r.wv.GetTitle() } -func (r *realConnector) GetHTML(sel string) (string, error) { return r.wv.GetHTML(sel) } -func (r *realConnector) ClearConsole() { r.wv.ClearConsole() } -func (r *realConnector) Close() error { return r.wv.Close() } -func (r *realConnector) SetViewport(w, h int) error { return r.wv.SetViewport(w, h) } +func (r *realConnector) Navigate(url string) error { return r.wv.Navigate(url) } +func (r *realConnector) Click(sel string) error { return r.wv.Click(sel) } +func (r *realConnector) Type(sel, text string) error { return r.wv.Type(sel, text) } +func (r *realConnector) Evaluate(script string) (any, error) { return r.wv.Evaluate(script) } +func (r *realConnector) Screenshot() ([]byte, error) { return r.wv.Screenshot() } +func (r *realConnector) GetURL() (string, error) { return r.wv.GetURL() } +func (r *realConnector) GetTitle() (string, error) { return r.wv.GetTitle() } +func (r *realConnector) GetHTML(sel string) (string, error) { return r.wv.GetHTML(sel) } +func (r *realConnector) ClearConsole() { r.wv.ClearConsole() } +func (r *realConnector) Close() error { return r.wv.Close() } +func (r *realConnector) SetViewport(w, h int) error { return r.wv.SetViewport(w, h) } func (r *realConnector) UploadFile(sel string, p []string) error { return r.wv.UploadFile(sel, p) } func (r *realConnector) Hover(sel string) error { diff --git a/pkg/webview/service_test.go b/pkg/webview/service_test.go index 45a8f20..6551369 100644 --- a/pkg/webview/service_test.go +++ b/pkg/webview/service_test.go @@ -37,21 +37,41 @@ type mockConnector struct { consoleClearCalled bool } -func (m *mockConnector) Navigate(url string) error { m.lastNavURL = url; return nil } -func (m *mockConnector) Click(sel string) error { m.lastClickSel = sel; return nil } -func (m *mockConnector) Type(sel, text string) error { m.lastTypeSel = sel; m.lastTypeText = text; return nil } -func (m *mockConnector) Hover(sel string) error { m.lastHoverSel = sel; return nil } -func (m *mockConnector) Select(sel, val string) error { m.lastSelectSel = sel; m.lastSelectVal = val; return nil } -func (m *mockConnector) Check(sel string, c bool) error { m.lastCheckSel = sel; m.lastCheckVal = c; return nil } -func (m *mockConnector) Evaluate(s string) (any, error) { return m.evalResult, nil } -func (m *mockConnector) Screenshot() ([]byte, error) { return m.screenshot, nil } -func (m *mockConnector) GetURL() (string, error) { return m.url, nil } -func (m *mockConnector) GetTitle() (string, error) { return m.title, nil } +func (m *mockConnector) Navigate(url string) error { m.lastNavURL = url; return nil } +func (m *mockConnector) Click(sel string) error { m.lastClickSel = sel; return nil } +func (m *mockConnector) Type(sel, text string) error { + m.lastTypeSel = sel + m.lastTypeText = text + return nil +} +func (m *mockConnector) Hover(sel string) error { m.lastHoverSel = sel; return nil } +func (m *mockConnector) Select(sel, val string) error { + m.lastSelectSel = sel + m.lastSelectVal = val + return nil +} +func (m *mockConnector) Check(sel string, c bool) error { + m.lastCheckSel = sel + m.lastCheckVal = c + return nil +} +func (m *mockConnector) Evaluate(s string) (any, error) { return m.evalResult, nil } +func (m *mockConnector) Screenshot() ([]byte, error) { return m.screenshot, nil } +func (m *mockConnector) GetURL() (string, error) { return m.url, nil } +func (m *mockConnector) GetTitle() (string, error) { return m.title, nil } func (m *mockConnector) GetHTML(sel string) (string, error) { return m.html, nil } -func (m *mockConnector) ClearConsole() { m.consoleClearCalled = true } -func (m *mockConnector) Close() error { m.closed = true; return nil } -func (m *mockConnector) SetViewport(w, h int) error { m.lastViewportW = w; m.lastViewportH = h; return nil } -func (m *mockConnector) UploadFile(sel string, p []string) error { m.lastUploadSel = sel; m.lastUploadPaths = p; return nil } +func (m *mockConnector) ClearConsole() { m.consoleClearCalled = true } +func (m *mockConnector) Close() error { m.closed = true; return nil } +func (m *mockConnector) SetViewport(w, h int) error { + m.lastViewportW = w + m.lastViewportH = h + return nil +} +func (m *mockConnector) UploadFile(sel string, p []string) error { + m.lastUploadSel = sel + m.lastUploadPaths = p + return nil +} func (m *mockConnector) QuerySelector(sel string) (*ElementInfo, error) { if len(m.elements) > 0 { @@ -174,6 +194,16 @@ func TestTaskClearConsole_Good(t *testing.T) { assert.True(t, mock.consoleClearCalled) } +func TestTaskDevTools_Good(t *testing.T) { + _, c := newTestService(t, &mockConnector{}) + _, handled, err := c.PERFORM(TaskOpenDevTools{Window: "main"}) + require.NoError(t, err) + assert.True(t, handled) + _, handled, err = c.PERFORM(TaskCloseDevTools{Window: "main"}) + require.NoError(t, err) + assert.True(t, handled) +} + func TestConnectionCleanup_Good(t *testing.T) { mock := &mockConnector{} _, c := newTestService(t, mock) diff --git a/pkg/window/messages.go b/pkg/window/messages.go index b5d1a13..dea640c 100644 --- a/pkg/window/messages.go +++ b/pkg/window/messages.go @@ -24,6 +24,19 @@ type QueryWindowByName struct{ Name string } // Result: map[string]any type QueryConfig struct{} +// QueryFindSpace returns a suggested free placement for a new window. +type QueryFindSpace struct { + Width int + Height int +} + +// QueryLayoutSuggestion returns a layout recommendation for the current screen. +type QueryLayoutSuggestion struct { + WindowCount int + ScreenWidth int + ScreenHeight int +} + // --- Tasks (side-effects) --- // TaskOpenWindow creates a new window. Result: WindowInfo @@ -105,6 +118,18 @@ type TaskSnapWindow struct { Position string // "left", "right", "top", "bottom", "top-left", "top-right", "bottom-left", "bottom-right", "center" } +// TaskArrangePair places two windows side-by-side in a balanced split. +type TaskArrangePair struct { + First string + Second string +} + +// TaskBesideEditor places a target window beside an editor/IDE window. +type TaskBesideEditor struct { + Editor string + Window string +} + // TaskSaveConfig persists this service's config section via the display orchestrator. type TaskSaveConfig struct{ Value map[string]any } @@ -127,7 +152,28 @@ type ActionWindowFocused struct{ Name string } type ActionWindowBlurred struct{ Name string } type ActionFilesDropped struct { - Name string `json:"name"` // window name + Name string `json:"name"` // window name Paths []string `json:"paths"` TargetID string `json:"targetId,omitempty"` } + +// SpaceInfo describes a suggested empty area on the screen. +type SpaceInfo struct { + X int `json:"x"` + Y int `json:"y"` + Width int `json:"width"` + Height int `json:"height"` + ScreenWidth int `json:"screenWidth"` + ScreenHeight int `json:"screenHeight"` + Reason string `json:"reason,omitempty"` +} + +// LayoutSuggestion describes a recommended layout for a screen. +type LayoutSuggestion struct { + Mode string `json:"mode"` + Columns int `json:"columns"` + Rows int `json:"rows"` + PrimaryWidth int `json:"primaryWidth"` + SecondaryWidth int `json:"secondaryWidth"` + Description string `json:"description"` +} diff --git a/pkg/window/mock_platform.go b/pkg/window/mock_platform.go index 9dde9a6..41c4052 100644 --- a/pkg/window/mock_platform.go +++ b/pkg/window/mock_platform.go @@ -38,28 +38,32 @@ type MockWindow struct { fileDropHandlers []func(paths []string, targetID string) } -func (w *MockWindow) Name() string { return w.name } -func (w *MockWindow) Title() string { return w.title } -func (w *MockWindow) Position() (int, int) { return w.x, w.y } -func (w *MockWindow) Size() (int, int) { return w.width, w.height } -func (w *MockWindow) IsMaximised() bool { return w.maximised } -func (w *MockWindow) IsFocused() bool { return w.focused } -func (w *MockWindow) SetTitle(title string) { w.title = title } -func (w *MockWindow) SetPosition(x, y int) { w.x = x; w.y = y } -func (w *MockWindow) SetSize(width, height int) { w.width = width; w.height = height } -func (w *MockWindow) SetBackgroundColour(r, g, b, a uint8) {} -func (w *MockWindow) SetVisibility(visible bool) { w.visible = visible } -func (w *MockWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop } -func (w *MockWindow) Maximise() { w.maximised = true } -func (w *MockWindow) Restore() { w.maximised = false } -func (w *MockWindow) Minimise() {} -func (w *MockWindow) Focus() { w.focused = true } -func (w *MockWindow) Close() { w.closed = true } -func (w *MockWindow) Show() { w.visible = true } -func (w *MockWindow) Hide() { w.visible = false } -func (w *MockWindow) Fullscreen() {} -func (w *MockWindow) UnFullscreen() {} -func (w *MockWindow) OnWindowEvent(handler func(WindowEvent)) { w.eventHandlers = append(w.eventHandlers, handler) } +func (w *MockWindow) Name() string { return w.name } +func (w *MockWindow) Title() string { return w.title } +func (w *MockWindow) Position() (int, int) { return w.x, w.y } +func (w *MockWindow) Size() (int, int) { return w.width, w.height } +func (w *MockWindow) IsMaximised() bool { return w.maximised } +func (w *MockWindow) IsFocused() bool { return w.focused } +func (w *MockWindow) SetTitle(title string) { w.title = title } +func (w *MockWindow) SetPosition(x, y int) { w.x = x; w.y = y } +func (w *MockWindow) SetSize(width, height int) { w.width = width; w.height = height } +func (w *MockWindow) SetBackgroundColour(r, g, b, a uint8) {} +func (w *MockWindow) SetVisibility(visible bool) { w.visible = visible } +func (w *MockWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop } +func (w *MockWindow) Maximise() { w.maximised = true } +func (w *MockWindow) Restore() { w.maximised = false } +func (w *MockWindow) Minimise() {} +func (w *MockWindow) Focus() { w.focused = true } +func (w *MockWindow) Close() { w.closed = true } +func (w *MockWindow) Show() { w.visible = true } +func (w *MockWindow) Hide() { w.visible = false } +func (w *MockWindow) Fullscreen() {} +func (w *MockWindow) UnFullscreen() {} +func (w *MockWindow) OpenDevTools() {} +func (w *MockWindow) CloseDevTools() {} +func (w *MockWindow) OnWindowEvent(handler func(WindowEvent)) { + w.eventHandlers = append(w.eventHandlers, handler) +} func (w *MockWindow) OnFileDrop(handler func(paths []string, targetID string)) { w.fileDropHandlers = append(w.fileDropHandlers, handler) } diff --git a/pkg/window/mock_test.go b/pkg/window/mock_test.go index 72d54ca..d77602d 100644 --- a/pkg/window/mock_test.go +++ b/pkg/window/mock_test.go @@ -37,28 +37,32 @@ type mockWindow struct { fileDropHandlers []func(paths []string, targetID string) } -func (w *mockWindow) Name() string { return w.name } -func (w *mockWindow) Title() string { return w.title } -func (w *mockWindow) Position() (int, int) { return w.x, w.y } -func (w *mockWindow) Size() (int, int) { return w.width, w.height } -func (w *mockWindow) IsMaximised() bool { return w.maximised } -func (w *mockWindow) IsFocused() bool { return w.focused } -func (w *mockWindow) SetTitle(title string) { w.title = title } -func (w *mockWindow) SetPosition(x, y int) { w.x = x; w.y = y } -func (w *mockWindow) SetSize(width, height int) { w.width = width; w.height = height } -func (w *mockWindow) SetBackgroundColour(r, g, b, a uint8) {} -func (w *mockWindow) SetVisibility(visible bool) { w.visible = visible } -func (w *mockWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop } -func (w *mockWindow) Maximise() { w.maximised = true } -func (w *mockWindow) Restore() { w.maximised = false } -func (w *mockWindow) Minimise() {} -func (w *mockWindow) Focus() { w.focused = true } -func (w *mockWindow) Close() { w.closed = true } -func (w *mockWindow) Show() { w.visible = true } -func (w *mockWindow) Hide() { w.visible = false } -func (w *mockWindow) Fullscreen() {} -func (w *mockWindow) UnFullscreen() {} -func (w *mockWindow) OnWindowEvent(handler func(WindowEvent)) { w.eventHandlers = append(w.eventHandlers, handler) } +func (w *mockWindow) Name() string { return w.name } +func (w *mockWindow) Title() string { return w.title } +func (w *mockWindow) Position() (int, int) { return w.x, w.y } +func (w *mockWindow) Size() (int, int) { return w.width, w.height } +func (w *mockWindow) IsMaximised() bool { return w.maximised } +func (w *mockWindow) IsFocused() bool { return w.focused } +func (w *mockWindow) SetTitle(title string) { w.title = title } +func (w *mockWindow) SetPosition(x, y int) { w.x = x; w.y = y } +func (w *mockWindow) SetSize(width, height int) { w.width = width; w.height = height } +func (w *mockWindow) SetBackgroundColour(r, g, b, a uint8) {} +func (w *mockWindow) SetVisibility(visible bool) { w.visible = visible } +func (w *mockWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop } +func (w *mockWindow) Maximise() { w.maximised = true } +func (w *mockWindow) Restore() { w.maximised = false } +func (w *mockWindow) Minimise() {} +func (w *mockWindow) Focus() { w.focused = true } +func (w *mockWindow) Close() { w.closed = true } +func (w *mockWindow) Show() { w.visible = true } +func (w *mockWindow) Hide() { w.visible = false } +func (w *mockWindow) Fullscreen() {} +func (w *mockWindow) UnFullscreen() {} +func (w *mockWindow) OpenDevTools() {} +func (w *mockWindow) CloseDevTools() {} +func (w *mockWindow) OnWindowEvent(handler func(WindowEvent)) { + w.eventHandlers = append(w.eventHandlers, handler) +} func (w *mockWindow) OnFileDrop(handler func(paths []string, targetID string)) { w.fileDropHandlers = append(w.fileDropHandlers, handler) } diff --git a/pkg/window/service.go b/pkg/window/service.go index 040ab95..06ea1f6 100644 --- a/pkg/window/service.go +++ b/pkg/window/service.go @@ -3,8 +3,10 @@ package window import ( "context" "fmt" + "strings" "forge.lthn.ai/core/go/pkg/core" + "forge.lthn.ai/core/gui/pkg/screen" ) // Options holds configuration for the window service. @@ -76,6 +78,12 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) { return (*Layout)(nil), true, nil } return &l, true, nil + case QueryFindSpace: + screenW, screenH := s.primaryScreenSize() + return s.manager.FindSpace(screenW, screenH, q.Width, q.Height), true, nil + case QueryLayoutSuggestion: + screenW, screenH := s.primaryScreenSize() + return s.manager.SuggestLayout(screenW, screenH, q.WindowCount), true, nil default: return nil, false, nil } @@ -149,6 +157,10 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { return nil, true, s.taskTileWindows(t.Mode, t.Windows) case TaskSnapWindow: return nil, true, s.taskSnapWindow(t.Name, t.Position) + case TaskArrangePair: + return nil, true, s.taskArrangePair(t.First, t.Second) + case TaskBesideEditor: + return nil, true, s.taskBesideEditor(t.Editor, t.Window) default: return nil, false, nil } @@ -370,7 +382,83 @@ func (s *Service) taskSnapWindow(name, position string) error { if !ok { return fmt.Errorf("unknown snap position: %s", position) } - return s.manager.SnapWindow(name, pos, 1920, 1080) + screenW, screenH := s.primaryScreenSize() + return s.manager.SnapWindow(name, pos, screenW, screenH) +} + +func (s *Service) taskArrangePair(first, second string) error { + screenW, screenH := s.primaryScreenSize() + return s.manager.ArrangePair(first, second, screenW, screenH) +} + +func (s *Service) taskBesideEditor(editorName, windowName string) error { + screenW, screenH := s.primaryScreenSize() + if editorName == "" { + editorName = s.detectEditorWindow() + } + if editorName == "" { + return fmt.Errorf("editor window not found") + } + if windowName == "" { + windowName = s.detectCompanionWindow(editorName) + } + if windowName == "" { + return fmt.Errorf("companion window not found") + } + return s.manager.BesideEditor(editorName, windowName, screenW, screenH) +} + +func (s *Service) detectEditorWindow() string { + for _, info := range s.queryWindowList() { + if looksLikeEditor(info.Name, info.Title) { + return info.Name + } + } + return "" +} + +func (s *Service) detectCompanionWindow(editorName string) string { + for _, info := range s.queryWindowList() { + if info.Name == editorName { + continue + } + if !looksLikeEditor(info.Name, info.Title) { + return info.Name + } + } + return "" +} + +func looksLikeEditor(name, title string) bool { + return containsAny(name, "editor", "ide", "code", "workspace") || containsAny(title, "editor", "ide", "code") +} + +func containsAny(value string, needles ...string) bool { + lower := strings.ToLower(value) + for _, needle := range needles { + if strings.Contains(lower, needle) { + return true + } + } + return false +} + +func (s *Service) primaryScreenSize() (int, int) { + result, handled, err := s.Core().QUERY(screen.QueryPrimary{}) + if err == nil && handled { + if scr, ok := result.(*screen.Screen); ok && scr != nil { + if scr.WorkArea.Width > 0 && scr.WorkArea.Height > 0 { + return scr.WorkArea.Width, scr.WorkArea.Height + } + if scr.Bounds.Width > 0 && scr.Bounds.Height > 0 { + return scr.Bounds.Width, scr.Bounds.Height + } + if scr.Size.Width > 0 && scr.Size.Height > 0 { + return scr.Size.Width, scr.Size.Height + } + } + } + return 1920, 1080 } // Manager returns the underlying window Manager for direct access. diff --git a/pkg/window/wails.go b/pkg/window/wails.go index 1d2a722..8c439b9 100644 --- a/pkg/window/wails.go +++ b/pkg/window/wails.go @@ -18,23 +18,23 @@ func NewWailsPlatform(app *application.App) *WailsPlatform { func (wp *WailsPlatform) CreateWindow(opts PlatformWindowOptions) PlatformWindow { wOpts := application.WebviewWindowOptions{ - Name: opts.Name, - Title: opts.Title, - URL: opts.URL, - Width: opts.Width, - Height: opts.Height, - X: opts.X, - Y: opts.Y, - MinWidth: opts.MinWidth, - MinHeight: opts.MinHeight, - MaxWidth: opts.MaxWidth, - MaxHeight: opts.MaxHeight, - Frameless: opts.Frameless, - Hidden: opts.Hidden, - AlwaysOnTop: opts.AlwaysOnTop, - DisableResize: opts.DisableResize, - EnableFileDrop: opts.EnableFileDrop, - BackgroundColour: application.NewRGBA(opts.BackgroundColour[0], opts.BackgroundColour[1], opts.BackgroundColour[2], opts.BackgroundColour[3]), + Name: opts.Name, + Title: opts.Title, + URL: opts.URL, + Width: opts.Width, + Height: opts.Height, + X: opts.X, + Y: opts.Y, + MinWidth: opts.MinWidth, + MinHeight: opts.MinHeight, + MaxWidth: opts.MaxWidth, + MaxHeight: opts.MaxHeight, + Frameless: opts.Frameless, + Hidden: opts.Hidden, + AlwaysOnTop: opts.AlwaysOnTop, + DisableResize: opts.DisableResize, + EnableFileDrop: opts.EnableFileDrop, + BackgroundColour: application.NewRGBA(opts.BackgroundColour[0], opts.BackgroundColour[1], opts.BackgroundColour[2], opts.BackgroundColour[3]), } w := wp.app.Window.NewWithOptions(wOpts) return &wailsWindow{w: w, title: opts.Title} @@ -58,14 +58,14 @@ type wailsWindow struct { title string } -func (ww *wailsWindow) Name() string { return ww.w.Name() } -func (ww *wailsWindow) Title() string { return ww.title } -func (ww *wailsWindow) Position() (int, int) { return ww.w.Position() } -func (ww *wailsWindow) Size() (int, int) { return ww.w.Size() } -func (ww *wailsWindow) IsMaximised() bool { return ww.w.IsMaximised() } -func (ww *wailsWindow) IsFocused() bool { return ww.w.IsFocused() } -func (ww *wailsWindow) SetTitle(title string) { ww.title = title; ww.w.SetTitle(title) } -func (ww *wailsWindow) SetPosition(x, y int) { ww.w.SetPosition(x, y) } +func (ww *wailsWindow) Name() string { return ww.w.Name() } +func (ww *wailsWindow) Title() string { return ww.title } +func (ww *wailsWindow) Position() (int, int) { return ww.w.Position() } +func (ww *wailsWindow) Size() (int, int) { return ww.w.Size() } +func (ww *wailsWindow) IsMaximised() bool { return ww.w.IsMaximised() } +func (ww *wailsWindow) IsFocused() bool { return ww.w.IsFocused() } +func (ww *wailsWindow) SetTitle(title string) { ww.title = title; ww.w.SetTitle(title) } +func (ww *wailsWindow) SetPosition(x, y int) { ww.w.SetPosition(x, y) } func (ww *wailsWindow) SetSize(width, height int) { ww.w.SetSize(width, height) } func (ww *wailsWindow) SetBackgroundColour(r, g, b, a uint8) { ww.w.SetBackgroundColour(application.NewRGBA(r, g, b, a)) @@ -87,6 +87,8 @@ func (ww *wailsWindow) Show() { ww.w.Show() } func (ww *wailsWindow) Hide() { ww.w.Hide() } func (ww *wailsWindow) Fullscreen() { ww.w.Fullscreen() } func (ww *wailsWindow) UnFullscreen() { ww.w.UnFullscreen() } +func (ww *wailsWindow) OpenDevTools() { ww.w.OpenDevTools() } +func (ww *wailsWindow) CloseDevTools() {} func (ww *wailsWindow) OnWindowEvent(handler func(event WindowEvent)) { name := ww.w.Name() @@ -111,8 +113,8 @@ func (ww *wailsWindow) OnWindowEvent(handler func(event WindowEvent)) { data["y"] = y case "resize": w, h := ww.w.Size() - data["width"] = w - data["height"] = h + data["w"] = w + data["h"] = h } handler(WindowEvent{ Type: typeName, @@ -140,4 +142,3 @@ var _ PlatformWindow = (*wailsWindow)(nil) // Ensure WailsPlatform satisfies Platform at compile time. var _ Platform = (*WailsPlatform)(nil) - diff --git a/pkg/window/window.go b/pkg/window/window.go index 3692fe8..9fcbd98 100644 --- a/pkg/window/window.go +++ b/pkg/window/window.go @@ -3,24 +3,25 @@ package window import ( "fmt" + "math" "sync" ) // Window is CoreGUI's own window descriptor — NOT a Wails type alias. type Window struct { - Name string - Title string - URL string - Width, Height int - X, Y int + Name string + Title string + URL string + Width, Height int + X, Y int MinWidth, MinHeight int MaxWidth, MaxHeight int - Frameless bool - Hidden bool - AlwaysOnTop bool - BackgroundColour [4]uint8 - DisableResize bool - EnableFileDrop bool + Frameless bool + Hidden bool + AlwaysOnTop bool + BackgroundColour [4]uint8 + DisableResize bool + EnableFileDrop bool } // ToPlatformOptions converts a Window to PlatformWindowOptions for the backend. @@ -145,3 +146,151 @@ func (m *Manager) State() *StateManager { func (m *Manager) Layout() *LayoutManager { return m.layout } + +// SuggestLayout returns a simple layout recommendation for the given screen. +func (m *Manager) SuggestLayout(screenW, screenH, windowCount int) LayoutSuggestion { + if windowCount <= 1 { + return LayoutSuggestion{ + Mode: "single", + Columns: 1, + Rows: 1, + PrimaryWidth: screenW, + SecondaryWidth: 0, + Description: "Focus the primary window and keep the screen uncluttered.", + } + } + + if windowCount == 2 { + return LayoutSuggestion{ + Mode: "side-by-side", + Columns: 2, + Rows: 1, + PrimaryWidth: screenW / 2, + SecondaryWidth: screenW - (screenW / 2), + Description: "Split the screen into two equal panes.", + } + } + + if windowCount <= 4 { + return LayoutSuggestion{ + Mode: "quadrants", + Columns: 2, + Rows: 2, + PrimaryWidth: screenW / 2, + SecondaryWidth: screenW / 2, + Description: "Use a 2x2 grid for the active windows.", + } + } + + cols := 3 + rows := int(math.Ceil(float64(windowCount) / float64(cols))) + return LayoutSuggestion{ + Mode: "grid", + Columns: cols, + Rows: rows, + PrimaryWidth: screenW / cols, + SecondaryWidth: screenW / cols, + Description: "Use a dense grid to keep every window visible.", + } +} + +// FindSpace returns a free placement suggestion for a new window. +func (m *Manager) FindSpace(screenW, screenH, width, height int) SpaceInfo { + if width <= 0 { + width = screenW / 2 + } + if height <= 0 { + height = screenH / 2 + } + + occupied := make([]struct { + x, y, w, h int + }, 0) + for _, name := range m.List() { + pw, ok := m.Get(name) + if !ok { + continue + } + x, y := pw.Position() + w, h := pw.Size() + occupied = append(occupied, struct { + x, y, w, h int + }{x: x, y: y, w: w, h: h}) + } + + step := int(math.Max(40, math.Min(float64(width), float64(height))/6)) + if step < 40 { + step = 40 + } + + for y := 0; y+height <= screenH; y += step { + for x := 0; x+width <= screenW; x += step { + if !intersectsAny(x, y, width, height, occupied) { + return SpaceInfo{ + X: x, Y: y, Width: width, Height: height, + ScreenWidth: screenW, ScreenHeight: screenH, + Reason: "first available gap", + } + } + } + } + + return SpaceInfo{ + X: (screenW - width) / 2, Y: (screenH - height) / 2, + Width: width, Height: height, + ScreenWidth: screenW, ScreenHeight: screenH, + Reason: "center fallback", + } +} + +// ArrangePair places two windows side-by-side with a balanced split. +func (m *Manager) ArrangePair(first, second string, screenW, screenH int) error { + left, ok := m.Get(first) + if !ok { + return fmt.Errorf("window %q not found", first) + } + right, ok := m.Get(second) + if !ok { + return fmt.Errorf("window %q not found", second) + } + + leftW := screenW / 2 + rightW := screenW - leftW + left.SetPosition(0, 0) + left.SetSize(leftW, screenH) + right.SetPosition(leftW, 0) + right.SetSize(rightW, screenH) + return nil +} + +// BesideEditor places a target window beside an editor window, using a 70/30 split. +func (m *Manager) BesideEditor(editorName, windowName string, screenW, screenH int) error { + editor, ok := m.Get(editorName) + if !ok { + return fmt.Errorf("window %q not found", editorName) + } + target, ok := m.Get(windowName) + if !ok { + return fmt.Errorf("window %q not found", windowName) + } + + editorW := screenW * 70 / 100 + if editorW <= 0 { + editorW = screenW / 2 + } + targetW := screenW - editorW + editor.SetPosition(0, 0) + editor.SetSize(editorW, screenH) + target.SetPosition(editorW, 0) + target.SetSize(targetW, screenH) + return nil +} + +func intersectsAny(x, y, w, h int, occupied []struct{ x, y, w, h int }) bool { + for _, r := range occupied { + if x < r.x+r.w && x+w > r.x && y < r.y+r.h && y+h > r.y { + return true + } + } + return false +} diff --git a/pkg/window/window_test.go b/pkg/window/window_test.go index 44d1f09..9a2c2c2 100644 --- a/pkg/window/window_test.go +++ b/pkg/window/window_test.go @@ -328,3 +328,43 @@ func TestWorkflowLayout_Good(t *testing.T) { assert.Equal(t, "coding", WorkflowCoding.String()) assert.Equal(t, "debugging", WorkflowDebugging.String()) } + +func TestManager_SuggestLayout_Good(t *testing.T) { + m, _ := newTestManager() + suggestion := m.SuggestLayout(1920, 1080, 3) + assert.Equal(t, "quadrants", suggestion.Mode) + assert.Equal(t, 2, suggestion.Columns) +} + +func TestManager_FindSpace_Good(t *testing.T) { + m, _ := newTestManager() + _, _ = m.Open(WithName("one"), WithPosition(0, 0), WithSize(800, 600)) + space := m.FindSpace(1920, 1080, 400, 300) + assert.GreaterOrEqual(t, space.X, 0) + assert.GreaterOrEqual(t, space.Y, 0) +} + +func TestManager_ArrangePair_Good(t *testing.T) { + m, _ := newTestManager() + _, _ = m.Open(WithName("left"), WithSize(800, 600)) + _, _ = m.Open(WithName("right"), WithSize(800, 600)) + err := m.ArrangePair("left", "right", 1920, 1080) + require.NoError(t, err) + left, _ := m.Get("left") + x, _ := left.Position() + assert.Equal(t, 0, x) +} + +func TestManager_BesideEditor_Good(t *testing.T) { + m, _ := newTestManager() + _, _ = m.Open(WithName("editor")) + _, _ = m.Open(WithName("assistant")) + err := m.BesideEditor("editor", "assistant", 1920, 1080) + require.NoError(t, err) + editor, _ := m.Get("editor") + assistant, _ := m.Get("assistant") + ex, _ := editor.Size() + ax, _ := assistant.Position() + assert.Greater(t, ex, 0) + assert.Greater(t, ax, 0) +} diff --git a/stubs/wails/v3/go.mod b/stubs/wails/v3/go.mod new file mode 100644 index 0000000..7dcb832 --- /dev/null +++ b/stubs/wails/v3/go.mod @@ -0,0 +1,3 @@ +module github.com/wailsapp/wails/v3 + +go 1.26.0 diff --git a/stubs/wails/v3/pkg/application/application.go b/stubs/wails/v3/pkg/application/application.go new file mode 100644 index 0000000..db44bb3 --- /dev/null +++ b/stubs/wails/v3/pkg/application/application.go @@ -0,0 +1,323 @@ +package application + +import ( + "sync" + + "github.com/wailsapp/wails/v3/pkg/events" +) + +// RGBA represents a colour. +type RGBA struct { + R, G, B, A uint8 +} + +// NewRGBA creates a colour value. +func NewRGBA(r, g, b, a uint8) RGBA { return RGBA{R: r, G: g, B: b, A: a} } + +// Logger is a minimal logger used by the repo. +type Logger struct{} + +func (l *Logger) Info(message string, args ...any) {} + +// Context carries event data. +type Context struct { + droppedFiles []string + dropTargetData *DropTargetDetails +} + +func (c *Context) DroppedFiles() []string { + if c == nil { + return nil + } + out := make([]string, len(c.droppedFiles)) + copy(out, c.droppedFiles) + return out +} + +func (c *Context) DropTargetDetails() *DropTargetDetails { + if c == nil || c.dropTargetData == nil { + return nil + } + d := *c.dropTargetData + return &d +} + +// DropTargetDetails describes the drop target. +type DropTargetDetails struct { + ElementID string +} + +// WindowEvent wraps window event context. +type WindowEvent struct { + ctx *Context +} + +func (e *WindowEvent) Context() *Context { + if e == nil { + return nil + } + if e.ctx == nil { + e.ctx = &Context{} + } + return e.ctx +} + +// WebviewWindowOptions configures a new window. +type WebviewWindowOptions struct { + Name string + Title string + URL string + Width int + Height int + X int + Y int + MinWidth int + MinHeight int + MaxWidth int + MaxHeight int + Frameless bool + Hidden bool + AlwaysOnTop bool + DisableResize bool + EnableFileDrop bool + BackgroundColour RGBA +} + +// WebviewWindow is a lightweight in-memory window handle. +type WebviewWindow struct { + opts WebviewWindowOptions + title string + x, y int + width, height int + maximised bool + focused bool + visible bool + alwaysOnTop bool + fullscreen bool + devtoolsOpen bool + eventHandlers map[events.WindowEventType][]func(*WindowEvent) + mu sync.Mutex +} + +func newWebviewWindow(opts WebviewWindowOptions) *WebviewWindow { + return &WebviewWindow{ + opts: opts, + title: opts.Title, + x: opts.X, + y: opts.Y, + width: opts.Width, + height: opts.Height, + visible: !opts.Hidden, + alwaysOnTop: opts.AlwaysOnTop, + eventHandlers: make(map[events.WindowEventType][]func(*WindowEvent)), + } +} + +func (w *WebviewWindow) Name() string { return w.opts.Name } +func (w *WebviewWindow) Position() (int, int) { return w.x, w.y } +func (w *WebviewWindow) Size() (int, int) { return w.width, w.height } +func (w *WebviewWindow) IsMaximised() bool { return w.maximised } +func (w *WebviewWindow) IsFocused() bool { return w.focused } +func (w *WebviewWindow) SetTitle(title string) { w.title = title } +func (w *WebviewWindow) SetPosition(x, y int) { w.x, w.y = x, y } +func (w *WebviewWindow) SetSize(width, height int) { + w.width, w.height = width, height +} +func (w *WebviewWindow) SetBackgroundColour(colour RGBA) {} +func (w *WebviewWindow) SetVisibility(visible bool) { w.visible = visible } +func (w *WebviewWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop } +func (w *WebviewWindow) Maximise() { w.maximised = true } +func (w *WebviewWindow) Restore() { w.maximised = false } +func (w *WebviewWindow) Minimise() {} +func (w *WebviewWindow) Focus() { w.focused = true } +func (w *WebviewWindow) Close() {} +func (w *WebviewWindow) Show() { w.visible = true } +func (w *WebviewWindow) Hide() { w.visible = false } +func (w *WebviewWindow) Fullscreen() { w.fullscreen = true } +func (w *WebviewWindow) UnFullscreen() { w.fullscreen = false } +func (w *WebviewWindow) OpenDevTools() { w.devtoolsOpen = true } + +func (w *WebviewWindow) OnWindowEvent(eventType events.WindowEventType, callback func(event *WindowEvent)) func() { + w.mu.Lock() + defer w.mu.Unlock() + w.eventHandlers[eventType] = append(w.eventHandlers[eventType], callback) + return func() { + w.mu.Lock() + defer w.mu.Unlock() + handlers := w.eventHandlers[eventType] + if len(handlers) == 0 { + return + } + w.eventHandlers[eventType] = handlers[:len(handlers)-1] + } +} + +func (w *WebviewWindow) trigger(eventType events.WindowEventType, event *WindowEvent) { + w.mu.Lock() + handlers := append([]func(*WindowEvent){}, w.eventHandlers[eventType]...) + w.mu.Unlock() + for _, handler := range handlers { + handler(event) + } +} + +// WindowManager manages in-memory windows. +type WindowManager struct { + windows []*WebviewWindow +} + +func (wm *WindowManager) NewWithOptions(opts WebviewWindowOptions) *WebviewWindow { + w := newWebviewWindow(opts) + wm.windows = append(wm.windows, w) + return w +} + +func (wm *WindowManager) GetAll() []any { + out := make([]any, len(wm.windows)) + for i, w := range wm.windows { + out[i] = w + } + return out +} + +// Menu role constants. +type MenuRole int + +const ( + AppMenu MenuRole = iota + FileMenu + EditMenu + ViewMenu + WindowMenu + HelpMenu +) + +// Menu is a lightweight in-memory menu. +type Menu struct { + items []*MenuItem +} + +func NewMenu() *Menu { return &Menu{} } + +func (m *Menu) Add(label string) *MenuItem { + item := &MenuItem{label: label} + m.items = append(m.items, item) + return item +} + +func (m *Menu) AddSeparator() {} + +func (m *Menu) AddSubmenu(label string) *Menu { + return &Menu{} +} + +func (m *Menu) AddRole(role MenuRole) {} + +func (m *Menu) SetApplicationMenu(menu *Menu) {} + +// MenuItem is a lightweight menu item. +type MenuItem struct { + label string + accelerator string + tooltip string + checked bool + enabled bool + onClick func(*Context) +} + +func (mi *MenuItem) SetAccelerator(accel string) *MenuItem { + mi.accelerator = accel + return mi +} + +func (mi *MenuItem) SetTooltip(text string) *MenuItem { + mi.tooltip = text + return mi +} + +func (mi *MenuItem) SetChecked(checked bool) *MenuItem { + mi.checked = checked + return mi +} + +func (mi *MenuItem) SetEnabled(enabled bool) *MenuItem { + mi.enabled = enabled + return mi +} + +func (mi *MenuItem) OnClick(fn func(ctx *Context)) *MenuItem { + mi.onClick = fn + return mi +} + +// SystemTray models a tray icon. +type SystemTray struct { + icon []byte + templateIcon []byte + tooltip string + label string + menu *Menu +} + +func (st *SystemTray) SetIcon(icon []byte) *SystemTray { + st.icon = append([]byte(nil), icon...) + return st +} + +func (st *SystemTray) SetTemplateIcon(icon []byte) *SystemTray { + st.templateIcon = append([]byte(nil), icon...) + return st +} + +func (st *SystemTray) SetTooltip(tooltip string) { + st.tooltip = tooltip +} + +func (st *SystemTray) SetLabel(label string) { + st.label = label +} + +func (st *SystemTray) SetMenu(menu *Menu) *SystemTray { + st.menu = menu + return st +} + +func (st *SystemTray) Show() {} +func (st *SystemTray) Hide() {} +func (st *SystemTray) OnClick(callback func()) *SystemTray { return st } + +// SystemTrayManager creates trays. +type SystemTrayManager struct { + app *App +} + +func (stm *SystemTrayManager) New() *SystemTray { return &SystemTray{} } + +// MenuManager manages application menus. +type MenuManager struct { + appMenu *Menu +} + +func (mm *MenuManager) SetApplicationMenu(menu *Menu) { mm.appMenu = menu } + +// App is the top-level application container. +type App struct { + Window *WindowManager + Menu *MenuManager + SystemTray *SystemTrayManager + Logger *Logger + quit bool +} + +func NewApp() *App { + app := &App{} + app.Window = &WindowManager{} + app.Menu = &MenuManager{} + app.SystemTray = &SystemTrayManager{app: app} + app.Logger = &Logger{} + return app +} + +func (a *App) Quit() { a.quit = true } + +func (a *App) NewMenu() *Menu { return NewMenu() } diff --git a/stubs/wails/v3/pkg/events/events.go b/stubs/wails/v3/pkg/events/events.go new file mode 100644 index 0000000..63b1074 --- /dev/null +++ b/stubs/wails/v3/pkg/events/events.go @@ -0,0 +1,21 @@ +package events + +// WindowEventType identifies a window event. +type WindowEventType string + +// Common exposes the event names used by the repo. +var Common = struct { + WindowFocus WindowEventType + WindowLostFocus WindowEventType + WindowDidMove WindowEventType + WindowDidResize WindowEventType + WindowClosing WindowEventType + WindowFilesDropped WindowEventType +}{ + WindowFocus: "focus", + WindowLostFocus: "blur", + WindowDidMove: "move", + WindowDidResize: "resize", + WindowClosing: "close", + WindowFilesDropped: "files-dropped", +}