refactor(ax): align GUI APIs with AX principles
This commit is contained in:
parent
05a865d8f6
commit
35f8f5ec51
65 changed files with 1357 additions and 777 deletions
4
go.mod
4
go.mod
|
|
@ -5,6 +5,8 @@ go 1.26.0
|
|||
require (
|
||||
forge.lthn.ai/core/config v0.1.8
|
||||
forge.lthn.ai/core/go v0.3.3
|
||||
forge.lthn.ai/core/go-io v0.1.7
|
||||
forge.lthn.ai/core/go-log v0.0.4
|
||||
forge.lthn.ai/core/go-webview v0.1.7
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/leaanthony/u v1.1.1
|
||||
|
|
@ -16,8 +18,6 @@ require (
|
|||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
// pkg/browser/register.go
|
||||
package browser
|
||||
|
||||
import "forge.lthn.ai/core/go/pkg/core"
|
||||
|
||||
// Register creates a factory closure that captures the Platform adapter.
|
||||
// The returned function has the signature WithService requires: func(*Core) (any, error).
|
||||
func Register(p Platform) func(*core.Core) (any, error) {
|
||||
return func(c *core.Core) (any, error) {
|
||||
return &Service{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// pkg/browser/service.go
|
||||
package browser
|
||||
|
||||
import (
|
||||
|
|
@ -7,23 +6,18 @@ import (
|
|||
"forge.lthn.ai/core/go/pkg/core"
|
||||
)
|
||||
|
||||
// Options holds configuration for the browser service.
|
||||
type Options struct{}
|
||||
|
||||
// Service is a core.Service that delegates browser/file-open operations
|
||||
// to the platform. It is stateless — no queries, no actions.
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
platform Platform
|
||||
}
|
||||
|
||||
// OnStartup registers IPC handlers.
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
s.Core().RegisterTask(s.handleTask)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleIPCEvents is auto-discovered and registered by core.WithService.
|
||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,42 +1,28 @@
|
|||
// pkg/contextmenu/messages.go
|
||||
package contextmenu
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrMenuNotFound is returned when attempting to remove or get a menu
|
||||
// that does not exist in the registry.
|
||||
var ErrMenuNotFound = errors.New("contextmenu: menu not found")
|
||||
var ErrorMenuNotFound = errors.New("contextmenu: menu not found")
|
||||
|
||||
// --- Queries ---
|
||||
|
||||
// QueryGet returns a single context menu by name. Result: *ContextMenuDef (nil if not found)
|
||||
type QueryGet struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// QueryList returns all registered context menus. Result: map[string]ContextMenuDef
|
||||
type QueryList struct{}
|
||||
|
||||
// --- Tasks ---
|
||||
|
||||
// TaskAdd registers a context menu. Result: nil
|
||||
// If a menu with the same name already exists it is replaced (remove + re-add).
|
||||
type TaskAdd struct {
|
||||
Name string `json:"name"`
|
||||
Menu ContextMenuDef `json:"menu"`
|
||||
}
|
||||
|
||||
// TaskRemove unregisters a context menu. Result: nil
|
||||
// Returns ErrMenuNotFound if the menu does not exist.
|
||||
type TaskRemove struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
// ActionItemClicked is broadcast when a context menu item is clicked.
|
||||
// The Data field is populated from the CSS --custom-contextmenu-data property
|
||||
// on the element that triggered the context menu.
|
||||
type ActionItemClicked struct {
|
||||
MenuName string `json:"menuName"`
|
||||
ActionID string `json:"actionId"`
|
||||
|
|
|
|||
|
|
@ -1,16 +1,13 @@
|
|||
// pkg/contextmenu/register.go
|
||||
package contextmenu
|
||||
|
||||
import "forge.lthn.ai/core/go/pkg/core"
|
||||
|
||||
// Register creates a factory closure that captures the Platform adapter.
|
||||
// The returned function has the signature WithService requires: func(*Core) (any, error).
|
||||
func Register(p Platform) func(*core.Core) (any, error) {
|
||||
return func(c *core.Core) (any, error) {
|
||||
return &Service{
|
||||
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
||||
platform: p,
|
||||
menus: make(map[string]ContextMenuDef),
|
||||
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
||||
platform: p,
|
||||
registeredMenus: make(map[string]ContextMenuDef),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,26 +8,20 @@ import (
|
|||
"forge.lthn.ai/core/go/pkg/core"
|
||||
)
|
||||
|
||||
// Options holds configuration for the context menu service.
|
||||
type Options struct{}
|
||||
|
||||
// Service is a core.Service managing context menus via IPC.
|
||||
// It maintains an in-memory registry of menus (map[string]ContextMenuDef)
|
||||
// and delegates platform-level registration to the Platform interface.
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
platform Platform
|
||||
menus map[string]ContextMenuDef
|
||||
platform Platform
|
||||
registeredMenus map[string]ContextMenuDef
|
||||
}
|
||||
|
||||
// OnStartup registers IPC handlers.
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
s.Core().RegisterQuery(s.handleQuery)
|
||||
s.Core().RegisterTask(s.handleTask)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleIPCEvents is auto-discovered and registered by core.WithService.
|
||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -45,19 +39,17 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
|||
}
|
||||
}
|
||||
|
||||
// queryGet returns a single menu definition by name, or nil if not found.
|
||||
func (s *Service) queryGet(q QueryGet) *ContextMenuDef {
|
||||
menu, ok := s.menus[q.Name]
|
||||
menu, ok := s.registeredMenus[q.Name]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return &menu
|
||||
}
|
||||
|
||||
// queryList returns a copy of all registered menus.
|
||||
func (s *Service) queryList() map[string]ContextMenuDef {
|
||||
result := make(map[string]ContextMenuDef, len(s.menus))
|
||||
for k, v := range s.menus {
|
||||
result := make(map[string]ContextMenuDef, len(s.registeredMenus))
|
||||
for k, v := range s.registeredMenus {
|
||||
result[k] = v
|
||||
}
|
||||
return result
|
||||
|
|
@ -78,9 +70,9 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
|||
|
||||
func (s *Service) taskAdd(t TaskAdd) error {
|
||||
// If menu already exists, remove it first (replace semantics)
|
||||
if _, exists := s.menus[t.Name]; exists {
|
||||
if _, exists := s.registeredMenus[t.Name]; exists {
|
||||
_ = s.platform.Remove(t.Name)
|
||||
delete(s.menus, t.Name)
|
||||
delete(s.registeredMenus, t.Name)
|
||||
}
|
||||
|
||||
// Register on platform with a callback that broadcasts ActionItemClicked
|
||||
|
|
@ -95,13 +87,13 @@ func (s *Service) taskAdd(t TaskAdd) error {
|
|||
return fmt.Errorf("contextmenu: platform add failed: %w", err)
|
||||
}
|
||||
|
||||
s.menus[t.Name] = t.Menu
|
||||
s.registeredMenus[t.Name] = t.Menu
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) taskRemove(t TaskRemove) error {
|
||||
if _, exists := s.menus[t.Name]; !exists {
|
||||
return ErrMenuNotFound
|
||||
if _, exists := s.registeredMenus[t.Name]; !exists {
|
||||
return ErrorMenuNotFound
|
||||
}
|
||||
|
||||
err := s.platform.Remove(t.Name)
|
||||
|
|
@ -109,6 +101,6 @@ func (s *Service) taskRemove(t TaskRemove) error {
|
|||
return fmt.Errorf("contextmenu: platform remove failed: %w", err)
|
||||
}
|
||||
|
||||
delete(s.menus, t.Name)
|
||||
delete(s.registeredMenus, t.Name)
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ func TestTaskRemove_Bad_NotFound(t *testing.T) {
|
|||
|
||||
_, handled, err := c.PERFORM(TaskRemove{Name: "nonexistent"})
|
||||
assert.True(t, handled)
|
||||
assert.ErrorIs(t, err, ErrMenuNotFound)
|
||||
assert.ErrorIs(t, err, ErrorMenuNotFound)
|
||||
}
|
||||
|
||||
func TestQueryGet_Good(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,9 @@
|
|||
// pkg/dialog/messages.go
|
||||
package dialog
|
||||
|
||||
// TaskOpenFile shows an open file dialog. Result: []string (paths)
|
||||
type TaskOpenFile struct{ Opts OpenFileOptions }
|
||||
type TaskOpenFile struct{ Options OpenFileOptions }
|
||||
|
||||
// TaskSaveFile shows a save file dialog. Result: string (path)
|
||||
type TaskSaveFile struct{ Opts SaveFileOptions }
|
||||
type TaskSaveFile struct{ Options SaveFileOptions }
|
||||
|
||||
// TaskOpenDirectory shows a directory picker. Result: string (path)
|
||||
type TaskOpenDirectory struct{ Opts OpenDirectoryOptions }
|
||||
type TaskOpenDirectory struct{ Options OpenDirectoryOptions }
|
||||
|
||||
// TaskMessageDialog shows a message dialog. Result: string (button clicked)
|
||||
type TaskMessageDialog struct{ Opts MessageDialogOptions }
|
||||
type TaskMessageDialog struct{ Options MessageDialogOptions }
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ package dialog
|
|||
|
||||
// Platform abstracts the native dialog backend.
|
||||
type Platform interface {
|
||||
OpenFile(opts OpenFileOptions) ([]string, error)
|
||||
SaveFile(opts SaveFileOptions) (string, error)
|
||||
OpenDirectory(opts OpenDirectoryOptions) (string, error)
|
||||
MessageDialog(opts MessageDialogOptions) (string, error)
|
||||
OpenFile(options OpenFileOptions) ([]string, error)
|
||||
SaveFile(options SaveFileOptions) (string, error)
|
||||
OpenDirectory(options OpenDirectoryOptions) (string, error)
|
||||
MessageDialog(options MessageDialogOptions) (string, error)
|
||||
}
|
||||
|
||||
// DialogType represents the type of message dialog.
|
||||
|
|
|
|||
|
|
@ -7,16 +7,13 @@ import (
|
|||
"forge.lthn.ai/core/go/pkg/core"
|
||||
)
|
||||
|
||||
// Options holds configuration for the dialog service.
|
||||
type Options struct{}
|
||||
|
||||
// Service is a core.Service managing native dialogs via IPC.
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
platform Platform
|
||||
}
|
||||
|
||||
// Register creates a factory closure that captures the Platform adapter.
|
||||
func Register(p Platform) func(*core.Core) (any, error) {
|
||||
return func(c *core.Core) (any, error) {
|
||||
return &Service{
|
||||
|
|
@ -26,13 +23,11 @@ func Register(p Platform) func(*core.Core) (any, error) {
|
|||
}
|
||||
}
|
||||
|
||||
// OnStartup registers IPC handlers.
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
s.Core().RegisterTask(s.handleTask)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleIPCEvents is auto-discovered by core.WithService.
|
||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -40,16 +35,16 @@ func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
|||
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
||||
switch t := t.(type) {
|
||||
case TaskOpenFile:
|
||||
paths, err := s.platform.OpenFile(t.Opts)
|
||||
paths, err := s.platform.OpenFile(t.Options)
|
||||
return paths, true, err
|
||||
case TaskSaveFile:
|
||||
path, err := s.platform.SaveFile(t.Opts)
|
||||
path, err := s.platform.SaveFile(t.Options)
|
||||
return path, true, err
|
||||
case TaskOpenDirectory:
|
||||
path, err := s.platform.OpenDirectory(t.Opts)
|
||||
path, err := s.platform.OpenDirectory(t.Options)
|
||||
return path, true, err
|
||||
case TaskMessageDialog:
|
||||
button, err := s.platform.MessageDialog(t.Opts)
|
||||
button, err := s.platform.MessageDialog(t.Options)
|
||||
return button, true, err
|
||||
default:
|
||||
return nil, false, nil
|
||||
|
|
|
|||
|
|
@ -11,18 +11,18 @@ import (
|
|||
)
|
||||
|
||||
type mockPlatform struct {
|
||||
openFilePaths []string
|
||||
saveFilePath string
|
||||
openDirPath string
|
||||
messageButton string
|
||||
openFileErr error
|
||||
saveFileErr error
|
||||
openDirErr error
|
||||
messageErr error
|
||||
lastOpenOpts OpenFileOptions
|
||||
lastSaveOpts SaveFileOptions
|
||||
lastDirOpts OpenDirectoryOptions
|
||||
lastMsgOpts MessageDialogOptions
|
||||
openFilePaths []string
|
||||
saveFilePath string
|
||||
openDirPath string
|
||||
messageButton string
|
||||
openFileErr error
|
||||
saveFileErr error
|
||||
openDirErr error
|
||||
messageErr error
|
||||
lastOpenOpts OpenFileOptions
|
||||
lastSaveOpts SaveFileOptions
|
||||
lastDirOpts OpenDirectoryOptions
|
||||
lastMsgOpts MessageDialogOptions
|
||||
}
|
||||
|
||||
func (m *mockPlatform) OpenFile(opts OpenFileOptions) ([]string, error) {
|
||||
|
|
@ -70,7 +70,7 @@ func TestTaskOpenFile_Good(t *testing.T) {
|
|||
mock.openFilePaths = []string{"/a.txt", "/b.txt"}
|
||||
|
||||
result, handled, err := c.PERFORM(TaskOpenFile{
|
||||
Opts: OpenFileOptions{Title: "Pick", AllowMultiple: true},
|
||||
Options: OpenFileOptions{Title: "Pick", AllowMultiple: true},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
|
@ -83,7 +83,7 @@ func TestTaskOpenFile_Good(t *testing.T) {
|
|||
func TestTaskSaveFile_Good(t *testing.T) {
|
||||
_, c := newTestService(t)
|
||||
result, handled, err := c.PERFORM(TaskSaveFile{
|
||||
Opts: SaveFileOptions{Filename: "out.txt"},
|
||||
Options: SaveFileOptions{Filename: "out.txt"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
|
@ -93,7 +93,7 @@ func TestTaskSaveFile_Good(t *testing.T) {
|
|||
func TestTaskOpenDirectory_Good(t *testing.T) {
|
||||
_, c := newTestService(t)
|
||||
result, handled, err := c.PERFORM(TaskOpenDirectory{
|
||||
Opts: OpenDirectoryOptions{Title: "Pick Dir"},
|
||||
Options: OpenDirectoryOptions{Title: "Pick Dir"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
|
@ -105,7 +105,7 @@ func TestTaskMessageDialog_Good(t *testing.T) {
|
|||
mock.messageButton = "Yes"
|
||||
|
||||
result, handled, err := c.PERFORM(TaskMessageDialog{
|
||||
Opts: MessageDialogOptions{
|
||||
Options: MessageDialogOptions{
|
||||
Type: DialogQuestion, Title: "Confirm",
|
||||
Message: "Sure?", Buttons: []string{"Yes", "No"},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,14 +2,14 @@ package display
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"forge.lthn.ai/core/config"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
"encoding/json"
|
||||
|
||||
"forge.lthn.ai/core/gui/pkg/browser"
|
||||
"forge.lthn.ai/core/gui/pkg/contextmenu"
|
||||
|
|
@ -40,10 +40,9 @@ type Service struct {
|
|||
*core.ServiceRuntime[Options]
|
||||
wailsApp *application.App
|
||||
app App
|
||||
config Options
|
||||
configData map[string]map[string]any
|
||||
cfg *config.Config // config instance for file persistence
|
||||
events *WSEventManager
|
||||
configFile *config.Config // config instance for file persistence
|
||||
events *WSEventManager
|
||||
}
|
||||
|
||||
// New is the constructor for the display service.
|
||||
|
|
@ -116,7 +115,7 @@ func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
|||
case window.ActionWindowResized:
|
||||
if s.events != nil {
|
||||
s.events.Emit(Event{Type: EventWindowResize, Window: m.Name,
|
||||
Data: map[string]any{"w": m.W, "h": m.H}})
|
||||
Data: map[string]any{"w": m.Width, "h": m.Height}})
|
||||
}
|
||||
case window.ActionWindowFocused:
|
||||
if s.events != nil {
|
||||
|
|
@ -241,7 +240,7 @@ type WSMessage struct {
|
|||
func wsRequire(data map[string]any, key string) (string, error) {
|
||||
v, _ := data[key].(string)
|
||||
if v == "" {
|
||||
return "", fmt.Errorf("ws: missing required field %q", key)
|
||||
return "", coreerr.E("display.wsRequire", "missing required field \""+key+"\"", nil)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
|
@ -487,10 +486,10 @@ func (s *Service) handleTrayAction(actionID string) {
|
|||
result, handled, _ := s.Core().QUERY(environment.QueryInfo{})
|
||||
if handled {
|
||||
info := result.(environment.EnvironmentInfo)
|
||||
details := fmt.Sprintf("OS: %s\nArch: %s\nPlatform: %s %s",
|
||||
info.OS, info.Arch, info.Platform.Name, info.Platform.Version)
|
||||
details := "OS: " + info.OS + "\nArch: " + info.Arch + "\nPlatform: " +
|
||||
info.Platform.Name + " " + info.Platform.Version
|
||||
_, _, _ = s.Core().PERFORM(dialog.TaskMessageDialog{
|
||||
Opts: dialog.MessageDialogOptions{
|
||||
Options: dialog.MessageDialogOptions{
|
||||
Type: dialog.DialogInfo, Title: "Environment",
|
||||
Message: details, Buttons: []string{"OK"},
|
||||
},
|
||||
|
|
@ -512,23 +511,23 @@ func guiConfigPath() string {
|
|||
}
|
||||
|
||||
func (s *Service) loadConfig() {
|
||||
if s.cfg != nil {
|
||||
if s.configFile != nil {
|
||||
return // Already loaded (e.g., via loadConfigFrom in tests)
|
||||
}
|
||||
s.loadConfigFrom(guiConfigPath())
|
||||
}
|
||||
|
||||
func (s *Service) loadConfigFrom(path string) {
|
||||
cfg, err := config.New(config.WithPath(path))
|
||||
configFile, err := config.New(config.WithPath(path))
|
||||
if err != nil {
|
||||
// Non-critical — continue with empty configData
|
||||
return
|
||||
}
|
||||
s.cfg = cfg
|
||||
s.configFile = configFile
|
||||
|
||||
for _, section := range []string{"window", "systray", "menu"} {
|
||||
var data map[string]any
|
||||
if err := cfg.Get(section, &data); err == nil && data != nil {
|
||||
if err := configFile.Get(section, &data); err == nil && data != nil {
|
||||
s.configData[section] = data
|
||||
}
|
||||
}
|
||||
|
|
@ -550,16 +549,16 @@ func (s *Service) handleConfigQuery(c *core.Core, q core.Query) (any, bool, erro
|
|||
func (s *Service) handleConfigTask(c *core.Core, t core.Task) (any, bool, error) {
|
||||
switch t := t.(type) {
|
||||
case window.TaskSaveConfig:
|
||||
s.configData["window"] = t.Value
|
||||
s.persistSection("window", t.Value)
|
||||
s.configData["window"] = t.Config
|
||||
s.persistSection("window", t.Config)
|
||||
return nil, true, nil
|
||||
case systray.TaskSaveConfig:
|
||||
s.configData["systray"] = t.Value
|
||||
s.persistSection("systray", t.Value)
|
||||
s.configData["systray"] = t.Config
|
||||
s.persistSection("systray", t.Config)
|
||||
return nil, true, nil
|
||||
case menu.TaskSaveConfig:
|
||||
s.configData["menu"] = t.Value
|
||||
s.persistSection("menu", t.Value)
|
||||
s.configData["menu"] = t.Config
|
||||
s.persistSection("menu", t.Config)
|
||||
return nil, true, nil
|
||||
default:
|
||||
return nil, false, nil
|
||||
|
|
@ -567,11 +566,11 @@ func (s *Service) handleConfigTask(c *core.Core, t core.Task) (any, bool, error)
|
|||
}
|
||||
|
||||
func (s *Service) persistSection(key string, value map[string]any) {
|
||||
if s.cfg == nil {
|
||||
if s.configFile == nil {
|
||||
return
|
||||
}
|
||||
_ = s.cfg.Set(key, value)
|
||||
_ = s.cfg.Commit()
|
||||
_ = s.configFile.Set(key, value)
|
||||
_ = s.configFile.Commit()
|
||||
}
|
||||
|
||||
// --- Service accessors ---
|
||||
|
|
@ -588,8 +587,8 @@ func (s *Service) windowService() *window.Service {
|
|||
// --- Window Management (delegates via IPC) ---
|
||||
|
||||
// OpenWindow creates a new window via IPC.
|
||||
func (s *Service) OpenWindow(opts ...window.WindowOption) error {
|
||||
_, _, err := s.Core().PERFORM(window.TaskOpenWindow{Opts: opts})
|
||||
func (s *Service) OpenWindow(options ...window.WindowOption) error {
|
||||
_, _, err := s.Core().PERFORM(window.TaskOpenWindow{Options: options})
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -600,7 +599,7 @@ func (s *Service) GetWindowInfo(name string) (*window.WindowInfo, error) {
|
|||
return nil, err
|
||||
}
|
||||
if !handled {
|
||||
return nil, fmt.Errorf("window service not available")
|
||||
return nil, coreerr.E("display.GetWindowInfo", "window service not available", nil)
|
||||
}
|
||||
info, _ := result.(*window.WindowInfo)
|
||||
return info, nil
|
||||
|
|
@ -624,7 +623,7 @@ func (s *Service) SetWindowPosition(name string, x, y int) error {
|
|||
|
||||
// SetWindowSize resizes a window via IPC.
|
||||
func (s *Service) SetWindowSize(name string, width, height int) error {
|
||||
_, _, err := s.Core().PERFORM(window.TaskSetSize{Name: name, W: width, H: height})
|
||||
_, _, err := s.Core().PERFORM(window.TaskSetSize{Name: name, Width: width, Height: height})
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -633,7 +632,7 @@ func (s *Service) SetWindowBounds(name string, x, y, width, height int) error {
|
|||
if _, _, err := s.Core().PERFORM(window.TaskSetPosition{Name: name, X: x, Y: y}); err != nil {
|
||||
return err
|
||||
}
|
||||
_, _, err := s.Core().PERFORM(window.TaskSetSize{Name: name, W: width, H: height})
|
||||
_, _, err := s.Core().PERFORM(window.TaskSetSize{Name: name, Width: width, Height: height})
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -666,11 +665,11 @@ func (s *Service) CloseWindow(name string) error {
|
|||
func (s *Service) RestoreWindow(name string) error {
|
||||
ws := s.windowService()
|
||||
if ws == nil {
|
||||
return fmt.Errorf("window service not available")
|
||||
return coreerr.E("display.RestoreWindow", "window service not available", nil)
|
||||
}
|
||||
pw, ok := ws.Manager().Get(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("window not found: %s", name)
|
||||
return coreerr.E("display.RestoreWindow", "window not found: "+name, nil)
|
||||
}
|
||||
pw.Restore()
|
||||
return nil
|
||||
|
|
@ -681,11 +680,11 @@ func (s *Service) RestoreWindow(name string) error {
|
|||
func (s *Service) SetWindowVisibility(name string, visible bool) error {
|
||||
ws := s.windowService()
|
||||
if ws == nil {
|
||||
return fmt.Errorf("window service not available")
|
||||
return coreerr.E("display.SetWindowVisibility", "window service not available", nil)
|
||||
}
|
||||
pw, ok := ws.Manager().Get(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("window not found: %s", name)
|
||||
return coreerr.E("display.SetWindowVisibility", "window not found: "+name, nil)
|
||||
}
|
||||
pw.SetVisibility(visible)
|
||||
return nil
|
||||
|
|
@ -696,11 +695,11 @@ func (s *Service) SetWindowVisibility(name string, visible bool) error {
|
|||
func (s *Service) SetWindowAlwaysOnTop(name string, alwaysOnTop bool) error {
|
||||
ws := s.windowService()
|
||||
if ws == nil {
|
||||
return fmt.Errorf("window service not available")
|
||||
return coreerr.E("display.SetWindowAlwaysOnTop", "window service not available", nil)
|
||||
}
|
||||
pw, ok := ws.Manager().Get(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("window not found: %s", name)
|
||||
return coreerr.E("display.SetWindowAlwaysOnTop", "window not found: "+name, nil)
|
||||
}
|
||||
pw.SetAlwaysOnTop(alwaysOnTop)
|
||||
return nil
|
||||
|
|
@ -711,11 +710,11 @@ func (s *Service) SetWindowAlwaysOnTop(name string, alwaysOnTop bool) error {
|
|||
func (s *Service) SetWindowTitle(name string, title string) error {
|
||||
ws := s.windowService()
|
||||
if ws == nil {
|
||||
return fmt.Errorf("window service not available")
|
||||
return coreerr.E("display.SetWindowTitle", "window service not available", nil)
|
||||
}
|
||||
pw, ok := ws.Manager().Get(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("window not found: %s", name)
|
||||
return coreerr.E("display.SetWindowTitle", "window not found: "+name, nil)
|
||||
}
|
||||
pw.SetTitle(title)
|
||||
return nil
|
||||
|
|
@ -726,11 +725,11 @@ func (s *Service) SetWindowTitle(name string, title string) error {
|
|||
func (s *Service) SetWindowFullscreen(name string, fullscreen bool) error {
|
||||
ws := s.windowService()
|
||||
if ws == nil {
|
||||
return fmt.Errorf("window service not available")
|
||||
return coreerr.E("display.SetWindowFullscreen", "window service not available", nil)
|
||||
}
|
||||
pw, ok := ws.Manager().Get(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("window not found: %s", name)
|
||||
return coreerr.E("display.SetWindowFullscreen", "window not found: "+name, nil)
|
||||
}
|
||||
if fullscreen {
|
||||
pw.Fullscreen()
|
||||
|
|
@ -745,11 +744,11 @@ func (s *Service) SetWindowFullscreen(name string, fullscreen bool) error {
|
|||
func (s *Service) SetWindowBackgroundColour(name string, r, g, b, a uint8) error {
|
||||
ws := s.windowService()
|
||||
if ws == nil {
|
||||
return fmt.Errorf("window service not available")
|
||||
return coreerr.E("display.SetWindowBackgroundColour", "window service not available", nil)
|
||||
}
|
||||
pw, ok := ws.Manager().Get(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("window not found: %s", name)
|
||||
return coreerr.E("display.SetWindowBackgroundColour", "window not found: "+name, nil)
|
||||
}
|
||||
pw.SetBackgroundColour(r, g, b, a)
|
||||
return nil
|
||||
|
|
@ -773,7 +772,7 @@ func (s *Service) GetWindowTitle(name string) (string, error) {
|
|||
return "", err
|
||||
}
|
||||
if info == nil {
|
||||
return "", fmt.Errorf("window not found: %s", name)
|
||||
return "", coreerr.E("display.GetWindowTitle", "window not found: "+name, nil)
|
||||
}
|
||||
return info.Title, nil
|
||||
}
|
||||
|
|
@ -814,17 +813,17 @@ type CreateWindowOptions struct {
|
|||
}
|
||||
|
||||
// CreateWindow creates a new window with the specified options.
|
||||
func (s *Service) CreateWindow(opts CreateWindowOptions) (*window.WindowInfo, error) {
|
||||
if opts.Name == "" {
|
||||
return nil, fmt.Errorf("window name is required")
|
||||
func (s *Service) CreateWindow(options CreateWindowOptions) (*window.WindowInfo, error) {
|
||||
if options.Name == "" {
|
||||
return nil, coreerr.E("display.CreateWindow", "window name is required", nil)
|
||||
}
|
||||
result, _, err := s.Core().PERFORM(window.TaskOpenWindow{
|
||||
Opts: []window.WindowOption{
|
||||
window.WithName(opts.Name),
|
||||
window.WithTitle(opts.Title),
|
||||
window.WithURL(opts.URL),
|
||||
window.WithSize(opts.Width, opts.Height),
|
||||
window.WithPosition(opts.X, opts.Y),
|
||||
Options: []window.WindowOption{
|
||||
window.WithName(options.Name),
|
||||
window.WithTitle(options.Title),
|
||||
window.WithURL(options.URL),
|
||||
window.WithSize(options.Width, options.Height),
|
||||
window.WithPosition(options.X, options.Y),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
|
|
@ -840,7 +839,7 @@ func (s *Service) CreateWindow(opts CreateWindowOptions) (*window.WindowInfo, er
|
|||
func (s *Service) SaveLayout(name string) error {
|
||||
ws := s.windowService()
|
||||
if ws == nil {
|
||||
return fmt.Errorf("window service not available")
|
||||
return coreerr.E("display.SaveLayout", "window service not available", nil)
|
||||
}
|
||||
states := make(map[string]window.WindowState)
|
||||
for _, n := range ws.Manager().List() {
|
||||
|
|
@ -857,11 +856,11 @@ func (s *Service) SaveLayout(name string) error {
|
|||
func (s *Service) RestoreLayout(name string) error {
|
||||
ws := s.windowService()
|
||||
if ws == nil {
|
||||
return fmt.Errorf("window service not available")
|
||||
return coreerr.E("display.RestoreLayout", "window service not available", nil)
|
||||
}
|
||||
layout, ok := ws.Manager().Layout().GetLayout(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("layout not found: %s", name)
|
||||
return coreerr.E("display.RestoreLayout", "layout not found: "+name, nil)
|
||||
}
|
||||
for wName, state := range layout.Windows {
|
||||
if pw, ok := ws.Manager().Get(wName); ok {
|
||||
|
|
@ -890,7 +889,7 @@ func (s *Service) ListLayouts() []window.LayoutInfo {
|
|||
func (s *Service) DeleteLayout(name string) error {
|
||||
ws := s.windowService()
|
||||
if ws == nil {
|
||||
return fmt.Errorf("window service not available")
|
||||
return coreerr.E("display.DeleteLayout", "window service not available", nil)
|
||||
}
|
||||
ws.Manager().Layout().DeleteLayout(name)
|
||||
return nil
|
||||
|
|
@ -915,25 +914,54 @@ func (s *Service) GetLayout(name string) *window.Layout {
|
|||
func (s *Service) TileWindows(mode window.TileMode, windowNames []string) error {
|
||||
ws := s.windowService()
|
||||
if ws == nil {
|
||||
return fmt.Errorf("window service not available")
|
||||
return coreerr.E("display.TileWindows", "window service not available", nil)
|
||||
}
|
||||
return ws.Manager().TileWindows(mode, windowNames, 1920, 1080) // TODO: use actual screen size
|
||||
screenWidth, screenHeight := s.primaryScreenSize()
|
||||
return ws.Manager().TileWindows(mode, windowNames, screenWidth, screenHeight)
|
||||
}
|
||||
|
||||
// SnapWindow snaps a window to a screen edge or corner.
|
||||
func (s *Service) SnapWindow(name string, position window.SnapPosition) error {
|
||||
ws := s.windowService()
|
||||
if ws == nil {
|
||||
return fmt.Errorf("window service not available")
|
||||
return coreerr.E("display.SnapWindow", "window service not available", nil)
|
||||
}
|
||||
return ws.Manager().SnapWindow(name, position, 1920, 1080) // TODO: use actual screen size
|
||||
screenWidth, screenHeight := s.primaryScreenSize()
|
||||
return ws.Manager().SnapWindow(name, position, screenWidth, screenHeight)
|
||||
}
|
||||
|
||||
func (s *Service) primaryScreenSize() (int, int) {
|
||||
const fallbackWidth = 1920
|
||||
const fallbackHeight = 1080
|
||||
|
||||
result, handled, err := s.Core().QUERY(screen.QueryPrimary{})
|
||||
if err != nil || !handled {
|
||||
return fallbackWidth, fallbackHeight
|
||||
}
|
||||
|
||||
primary, ok := result.(*screen.Screen)
|
||||
if !ok || primary == nil {
|
||||
return fallbackWidth, fallbackHeight
|
||||
}
|
||||
|
||||
width := primary.WorkArea.Width
|
||||
height := primary.WorkArea.Height
|
||||
if width <= 0 || height <= 0 {
|
||||
width = primary.Bounds.Width
|
||||
height = primary.Bounds.Height
|
||||
}
|
||||
if width <= 0 || height <= 0 {
|
||||
return fallbackWidth, fallbackHeight
|
||||
}
|
||||
|
||||
return width, height
|
||||
}
|
||||
|
||||
// StackWindows arranges windows in a cascade pattern.
|
||||
func (s *Service) StackWindows(windowNames []string, offsetX, offsetY int) error {
|
||||
ws := s.windowService()
|
||||
if ws == nil {
|
||||
return fmt.Errorf("window service not available")
|
||||
return coreerr.E("display.StackWindows", "window service not available", nil)
|
||||
}
|
||||
return ws.Manager().StackWindows(windowNames, offsetX, offsetY)
|
||||
}
|
||||
|
|
@ -942,7 +970,7 @@ func (s *Service) StackWindows(windowNames []string, offsetX, offsetY int) error
|
|||
func (s *Service) ApplyWorkflowLayout(workflow window.WorkflowLayout) error {
|
||||
ws := s.windowService()
|
||||
if ws == nil {
|
||||
return fmt.Errorf("window service not available")
|
||||
return coreerr.E("display.ApplyWorkflowLayout", "window service not available", nil)
|
||||
}
|
||||
return ws.Manager().ApplyWorkflow(workflow, ws.Manager().List(), 1920, 1080)
|
||||
}
|
||||
|
|
@ -993,7 +1021,7 @@ func ptr[T any](v T) *T { return &v }
|
|||
|
||||
func (s *Service) handleNewWorkspace() {
|
||||
_, _, _ = s.Core().PERFORM(window.TaskOpenWindow{
|
||||
Opts: []window.WindowOption{
|
||||
Options: []window.WindowOption{
|
||||
window.WithName("workspace-new"),
|
||||
window.WithTitle("New Workspace"),
|
||||
window.WithURL("/workspace/new"),
|
||||
|
|
@ -1016,7 +1044,7 @@ func (s *Service) handleListWorkspaces() {
|
|||
|
||||
func (s *Service) handleNewFile() {
|
||||
_, _, _ = s.Core().PERFORM(window.TaskOpenWindow{
|
||||
Opts: []window.WindowOption{
|
||||
Options: []window.WindowOption{
|
||||
window.WithName("editor"),
|
||||
window.WithTitle("New File - Editor"),
|
||||
window.WithURL("/#/developer/editor?new=true"),
|
||||
|
|
@ -1027,7 +1055,7 @@ func (s *Service) handleNewFile() {
|
|||
|
||||
func (s *Service) handleOpenFile() {
|
||||
result, handled, err := s.Core().PERFORM(dialog.TaskOpenFile{
|
||||
Opts: dialog.OpenFileOptions{
|
||||
Options: dialog.OpenFileOptions{
|
||||
Title: "Open File",
|
||||
AllowMultiple: false,
|
||||
},
|
||||
|
|
@ -1040,7 +1068,7 @@ func (s *Service) handleOpenFile() {
|
|||
return
|
||||
}
|
||||
_, _, _ = s.Core().PERFORM(window.TaskOpenWindow{
|
||||
Opts: []window.WindowOption{
|
||||
Options: []window.WindowOption{
|
||||
window.WithName("editor"),
|
||||
window.WithTitle(paths[0] + " - Editor"),
|
||||
window.WithURL("/#/developer/editor?file=" + paths[0]),
|
||||
|
|
@ -1052,7 +1080,7 @@ func (s *Service) handleOpenFile() {
|
|||
func (s *Service) handleSaveFile() { _ = s.Core().ACTION(ActionIDECommand{Command: "save"}) }
|
||||
func (s *Service) handleOpenEditor() {
|
||||
_, _, _ = s.Core().PERFORM(window.TaskOpenWindow{
|
||||
Opts: []window.WindowOption{
|
||||
Options: []window.WindowOption{
|
||||
window.WithName("editor"),
|
||||
window.WithTitle("Editor"),
|
||||
window.WithURL("/#/developer/editor"),
|
||||
|
|
@ -1062,7 +1090,7 @@ func (s *Service) handleOpenEditor() {
|
|||
}
|
||||
func (s *Service) handleOpenTerminal() {
|
||||
_, _, _ = s.Core().PERFORM(window.TaskOpenWindow{
|
||||
Opts: []window.WindowOption{
|
||||
Options: []window.WindowOption{
|
||||
window.WithName("terminal"),
|
||||
window.WithTitle("Terminal"),
|
||||
window.WithURL("/#/developer/terminal"),
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ func TestConfigTask_Good(t *testing.T) {
|
|||
_, c := newTestDisplayService(t)
|
||||
|
||||
newCfg := map[string]any{"default_width": 800}
|
||||
_, handled, err := c.PERFORM(window.TaskSaveConfig{Value: newCfg})
|
||||
_, handled, err := c.PERFORM(window.TaskSaveConfig{Config: newCfg})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
|
|
@ -121,7 +121,7 @@ func TestServiceConclave_Good(t *testing.T) {
|
|||
|
||||
// Open a window via IPC
|
||||
result, handled, err := c.PERFORM(window.TaskOpenWindow{
|
||||
Opts: []window.WindowOption{window.WithName("main")},
|
||||
Options: []window.WindowOption{window.WithName("main")},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
|
@ -413,7 +413,7 @@ func TestHandleIPCEvents_WindowOpened_Good(t *testing.T) {
|
|||
// Open a window — this should trigger ActionWindowOpened
|
||||
// which HandleIPCEvents should convert to a WS event
|
||||
result, handled, err := c.PERFORM(window.TaskOpenWindow{
|
||||
Opts: []window.WindowOption{window.WithName("test")},
|
||||
Options: []window.WindowOption{window.WithName("test")},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
|
@ -493,7 +493,7 @@ func TestHandleConfigTask_Persists_Good(t *testing.T) {
|
|||
c.ServiceStartup(context.Background(), nil)
|
||||
|
||||
_, handled, err := c.PERFORM(window.TaskSaveConfig{
|
||||
Value: map[string]any{"default_width": 1920},
|
||||
Config: map[string]any{"default_width": 1920},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ package display
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
|
|
@ -15,12 +15,12 @@ import (
|
|||
type EventType string
|
||||
|
||||
const (
|
||||
EventWindowFocus EventType = "window.focus"
|
||||
EventWindowBlur EventType = "window.blur"
|
||||
EventWindowMove EventType = "window.move"
|
||||
EventWindowResize EventType = "window.resize"
|
||||
EventWindowClose EventType = "window.close"
|
||||
EventWindowCreate EventType = "window.create"
|
||||
EventWindowFocus EventType = "window.focus"
|
||||
EventWindowBlur EventType = "window.blur"
|
||||
EventWindowMove EventType = "window.move"
|
||||
EventWindowResize EventType = "window.resize"
|
||||
EventWindowClose EventType = "window.close"
|
||||
EventWindowCreate EventType = "window.create"
|
||||
EventThemeChange EventType = "theme.change"
|
||||
EventScreenChange EventType = "screen.change"
|
||||
EventNotificationClick EventType = "notification.click"
|
||||
|
|
@ -202,7 +202,7 @@ func (em *WSEventManager) subscribe(conn *websocket.Conn, id string, eventTypes
|
|||
if id == "" {
|
||||
em.mu.Lock()
|
||||
em.nextSubID++
|
||||
id = fmt.Sprintf("sub-%d", em.nextSubID)
|
||||
id = "sub-" + strconv.Itoa(em.nextSubID)
|
||||
em.mu.Unlock()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
// pkg/dock/register.go
|
||||
package dock
|
||||
|
||||
import "forge.lthn.ai/core/go/pkg/core"
|
||||
|
||||
// Register creates a factory closure that captures the Platform adapter.
|
||||
// The returned function has the signature WithService requires: func(*Core) (any, error).
|
||||
func Register(p Platform) func(*core.Core) (any, error) {
|
||||
return func(c *core.Core) (any, error) {
|
||||
return &Service{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// pkg/dock/service.go
|
||||
package dock
|
||||
|
||||
import (
|
||||
|
|
@ -7,30 +6,23 @@ import (
|
|||
"forge.lthn.ai/core/go/pkg/core"
|
||||
)
|
||||
|
||||
// Options holds configuration for the dock service.
|
||||
type Options struct{}
|
||||
|
||||
// Service is a core.Service managing dock/taskbar operations via IPC.
|
||||
// It embeds ServiceRuntime for Core access and delegates to Platform.
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
platform Platform
|
||||
}
|
||||
|
||||
// OnStartup registers IPC handlers.
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
s.Core().RegisterQuery(s.handleQuery)
|
||||
s.Core().RegisterTask(s.handleTask)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleIPCEvents is auto-discovered and registered by core.WithService.
|
||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Query Handlers ---
|
||||
|
||||
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
||||
switch q.(type) {
|
||||
case QueryVisible:
|
||||
|
|
@ -40,8 +32,6 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
|||
}
|
||||
}
|
||||
|
||||
// --- Task Handlers ---
|
||||
|
||||
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
||||
switch t := t.(type) {
|
||||
case TaskShowIcon:
|
||||
|
|
|
|||
|
|
@ -1,40 +1,25 @@
|
|||
// pkg/keybinding/messages.go
|
||||
package keybinding
|
||||
|
||||
import "errors"
|
||||
|
||||
// ErrAlreadyRegistered is returned when attempting to add a binding
|
||||
// that already exists. Callers must TaskRemove first to rebind.
|
||||
var ErrAlreadyRegistered = errors.New("keybinding: accelerator already registered")
|
||||
var ErrorAlreadyRegistered = errors.New("keybinding: accelerator already registered")
|
||||
|
||||
// BindingInfo describes a registered keyboard shortcut.
|
||||
type BindingInfo struct {
|
||||
Accelerator string `json:"accelerator"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// --- Queries ---
|
||||
|
||||
// QueryList returns all registered bindings. Result: []BindingInfo
|
||||
type QueryList struct{}
|
||||
|
||||
// --- Tasks ---
|
||||
|
||||
// TaskAdd registers a new keyboard shortcut. Result: nil
|
||||
// Returns ErrAlreadyRegistered if the accelerator is already bound.
|
||||
type TaskAdd struct {
|
||||
Accelerator string `json:"accelerator"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// TaskRemove unregisters a keyboard shortcut. Result: nil
|
||||
type TaskRemove struct {
|
||||
Accelerator string `json:"accelerator"`
|
||||
}
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
// ActionTriggered is broadcast when a registered shortcut is activated.
|
||||
type ActionTriggered struct {
|
||||
Accelerator string `json:"accelerator"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,13 @@
|
|||
// pkg/keybinding/register.go
|
||||
package keybinding
|
||||
|
||||
import "forge.lthn.ai/core/go/pkg/core"
|
||||
|
||||
// Register creates a factory closure that captures the Platform adapter.
|
||||
// The returned function has the signature WithService requires: func(*Core) (any, error).
|
||||
func Register(p Platform) func(*core.Core) (any, error) {
|
||||
return func(c *core.Core) (any, error) {
|
||||
return &Service{
|
||||
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
||||
platform: p,
|
||||
bindings: make(map[string]BindingInfo),
|
||||
ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}),
|
||||
platform: p,
|
||||
registeredBindings: make(map[string]BindingInfo),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,31 +3,25 @@ package keybinding
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
)
|
||||
|
||||
// Options holds configuration for the keybinding service.
|
||||
type Options struct{}
|
||||
|
||||
// Service is a core.Service managing keyboard shortcuts via IPC.
|
||||
// It maintains an in-memory registry of bindings and delegates
|
||||
// platform-level registration to the Platform interface.
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
platform Platform
|
||||
bindings map[string]BindingInfo
|
||||
platform Platform
|
||||
registeredBindings map[string]BindingInfo
|
||||
}
|
||||
|
||||
// OnStartup registers IPC handlers.
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
s.Core().RegisterQuery(s.handleQuery)
|
||||
s.Core().RegisterTask(s.handleTask)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleIPCEvents is auto-discovered and registered by core.WithService.
|
||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -43,10 +37,9 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
|||
}
|
||||
}
|
||||
|
||||
// queryList reads from the in-memory registry (not platform.GetAll()).
|
||||
func (s *Service) queryList() []BindingInfo {
|
||||
result := make([]BindingInfo, 0, len(s.bindings))
|
||||
for _, info := range s.bindings {
|
||||
result := make([]BindingInfo, 0, len(s.registeredBindings))
|
||||
for _, info := range s.registeredBindings {
|
||||
result = append(result, info)
|
||||
}
|
||||
return result
|
||||
|
|
@ -66,8 +59,8 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
|||
}
|
||||
|
||||
func (s *Service) taskAdd(t TaskAdd) error {
|
||||
if _, exists := s.bindings[t.Accelerator]; exists {
|
||||
return ErrAlreadyRegistered
|
||||
if _, exists := s.registeredBindings[t.Accelerator]; exists {
|
||||
return ErrorAlreadyRegistered
|
||||
}
|
||||
|
||||
// Register on platform with a callback that broadcasts ActionTriggered
|
||||
|
|
@ -75,10 +68,10 @@ func (s *Service) taskAdd(t TaskAdd) error {
|
|||
_ = s.Core().ACTION(ActionTriggered{Accelerator: t.Accelerator})
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("keybinding: platform add failed: %w", err)
|
||||
return coreerr.E("keybinding.taskAdd", "platform add failed", err)
|
||||
}
|
||||
|
||||
s.bindings[t.Accelerator] = BindingInfo{
|
||||
s.registeredBindings[t.Accelerator] = BindingInfo{
|
||||
Accelerator: t.Accelerator,
|
||||
Description: t.Description,
|
||||
}
|
||||
|
|
@ -86,15 +79,15 @@ func (s *Service) taskAdd(t TaskAdd) error {
|
|||
}
|
||||
|
||||
func (s *Service) taskRemove(t TaskRemove) error {
|
||||
if _, exists := s.bindings[t.Accelerator]; !exists {
|
||||
return fmt.Errorf("keybinding: not registered: %s", t.Accelerator)
|
||||
if _, exists := s.registeredBindings[t.Accelerator]; !exists {
|
||||
return coreerr.E("keybinding.taskRemove", "not registered: "+t.Accelerator, nil)
|
||||
}
|
||||
|
||||
err := s.platform.Remove(t.Accelerator)
|
||||
if err != nil {
|
||||
return fmt.Errorf("keybinding: platform remove failed: %w", err)
|
||||
return coreerr.E("keybinding.taskRemove", "platform remove failed", err)
|
||||
}
|
||||
|
||||
delete(s.bindings, t.Accelerator)
|
||||
delete(s.registeredBindings, t.Accelerator)
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ func TestTaskAdd_Bad_Duplicate(t *testing.T) {
|
|||
// Second add with same accelerator should fail
|
||||
_, handled, err := c.PERFORM(TaskAdd{Accelerator: "Ctrl+S", Description: "Save Again"})
|
||||
assert.True(t, handled)
|
||||
assert.ErrorIs(t, err, ErrAlreadyRegistered)
|
||||
assert.ErrorIs(t, err, ErrorAlreadyRegistered)
|
||||
}
|
||||
|
||||
func TestTaskRemove_Good(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
// pkg/lifecycle/register.go
|
||||
package lifecycle
|
||||
|
||||
import "forge.lthn.ai/core/go/pkg/core"
|
||||
|
||||
// Register creates a factory closure that captures the Platform adapter.
|
||||
// The returned function has the signature WithService requires: func(*Core) (any, error).
|
||||
func Register(p Platform) func(*core.Core) (any, error) {
|
||||
return func(c *core.Core) (any, error) {
|
||||
return &Service{
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// pkg/lifecycle/service.go
|
||||
package lifecycle
|
||||
|
||||
import (
|
||||
|
|
@ -7,22 +6,15 @@ import (
|
|||
"forge.lthn.ai/core/go/pkg/core"
|
||||
)
|
||||
|
||||
// Options holds configuration for the lifecycle service.
|
||||
type Options struct{}
|
||||
|
||||
// Service is a core.Service that registers platform lifecycle callbacks
|
||||
// and broadcasts corresponding IPC Actions. It implements both Startable
|
||||
// and Stoppable: OnStartup registers all callbacks, OnShutdown cancels them.
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
platform Platform
|
||||
cancels []func()
|
||||
}
|
||||
|
||||
// OnStartup registers a platform callback for each EventType and for file-open.
|
||||
// Each callback broadcasts the corresponding Action via s.Core().ACTION().
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
// Register fire-and-forget event callbacks
|
||||
eventActions := map[EventType]func(){
|
||||
EventApplicationStarted: func() { _ = s.Core().ACTION(ActionApplicationStarted{}) },
|
||||
EventWillTerminate: func() { _ = s.Core().ACTION(ActionWillTerminate{}) },
|
||||
|
|
@ -38,7 +30,6 @@ func (s *Service) OnStartup(ctx context.Context) error {
|
|||
s.cancels = append(s.cancels, cancel)
|
||||
}
|
||||
|
||||
// Register file-open callback (carries data)
|
||||
cancel := s.platform.OnOpenedWithFile(func(path string) {
|
||||
_ = s.Core().ACTION(ActionOpenedWithFile{Path: path})
|
||||
})
|
||||
|
|
@ -47,7 +38,6 @@ func (s *Service) OnStartup(ctx context.Context) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// OnShutdown cancels all registered platform callbacks.
|
||||
func (s *Service) OnShutdown(ctx context.Context) error {
|
||||
for _, cancel := range s.cancels {
|
||||
cancel()
|
||||
|
|
@ -56,8 +46,6 @@ func (s *Service) OnShutdown(ctx context.Context) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// HandleIPCEvents is auto-discovered and registered by core.WithService.
|
||||
// Lifecycle events are all outbound (platform -> IPC) so there is nothing to handle here.
|
||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ package mcp
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"forge.lthn.ai/core/gui/pkg/clipboard"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
|
@ -23,7 +23,7 @@ func (s *Subsystem) clipboardRead(_ context.Context, _ *mcp.CallToolRequest, _ C
|
|||
}
|
||||
content, ok := result.(clipboard.ClipboardContent)
|
||||
if !ok {
|
||||
return nil, ClipboardReadOutput{}, fmt.Errorf("unexpected result type from clipboard read query")
|
||||
return nil, ClipboardReadOutput{}, coreerr.E("mcp.clipboardRead", "unexpected result type", nil)
|
||||
}
|
||||
return nil, ClipboardReadOutput{Content: content.Text}, nil
|
||||
}
|
||||
|
|
@ -44,7 +44,7 @@ func (s *Subsystem) clipboardWrite(_ context.Context, _ *mcp.CallToolRequest, in
|
|||
}
|
||||
success, ok := result.(bool)
|
||||
if !ok {
|
||||
return nil, ClipboardWriteOutput{}, fmt.Errorf("unexpected result type from clipboard write task")
|
||||
return nil, ClipboardWriteOutput{}, coreerr.E("mcp.clipboardWrite", "unexpected result type", nil)
|
||||
}
|
||||
return nil, ClipboardWriteOutput{Success: success}, nil
|
||||
}
|
||||
|
|
@ -63,7 +63,7 @@ func (s *Subsystem) clipboardHas(_ context.Context, _ *mcp.CallToolRequest, _ Cl
|
|||
}
|
||||
content, ok := result.(clipboard.ClipboardContent)
|
||||
if !ok {
|
||||
return nil, ClipboardHasOutput{}, fmt.Errorf("unexpected result type from clipboard has query")
|
||||
return nil, ClipboardHasOutput{}, coreerr.E("mcp.clipboardHas", "unexpected result type", nil)
|
||||
}
|
||||
return nil, ClipboardHasOutput{HasContent: content.HasContent}, nil
|
||||
}
|
||||
|
|
@ -82,7 +82,7 @@ func (s *Subsystem) clipboardClear(_ context.Context, _ *mcp.CallToolRequest, _
|
|||
}
|
||||
success, ok := result.(bool)
|
||||
if !ok {
|
||||
return nil, ClipboardClearOutput{}, fmt.Errorf("unexpected result type from clipboard clear task")
|
||||
return nil, ClipboardClearOutput{}, coreerr.E("mcp.clipboardClear", "unexpected result type", nil)
|
||||
}
|
||||
return nil, ClipboardClearOutput{Success: success}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,8 @@ package mcp
|
|||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"forge.lthn.ai/core/gui/pkg/contextmenu"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
|
@ -27,11 +27,11 @@ func (s *Subsystem) contextMenuAdd(_ context.Context, _ *mcp.CallToolRequest, in
|
|||
// Convert map[string]any to ContextMenuDef via JSON round-trip
|
||||
menuJSON, err := json.Marshal(input.Menu)
|
||||
if err != nil {
|
||||
return nil, ContextMenuAddOutput{}, fmt.Errorf("failed to marshal menu definition: %w", err)
|
||||
return nil, ContextMenuAddOutput{}, coreerr.E("mcp.contextMenuAdd", "failed to marshal menu definition", err)
|
||||
}
|
||||
var menuDef contextmenu.ContextMenuDef
|
||||
if err := json.Unmarshal(menuJSON, &menuDef); err != nil {
|
||||
return nil, ContextMenuAddOutput{}, fmt.Errorf("failed to unmarshal menu definition: %w", err)
|
||||
return nil, ContextMenuAddOutput{}, coreerr.E("mcp.contextMenuAdd", "failed to unmarshal menu definition", err)
|
||||
}
|
||||
_, _, err = s.core.PERFORM(contextmenu.TaskAdd{Name: input.Name, Menu: menuDef})
|
||||
if err != nil {
|
||||
|
|
@ -73,7 +73,7 @@ func (s *Subsystem) contextMenuGet(_ context.Context, _ *mcp.CallToolRequest, in
|
|||
}
|
||||
menu, ok := result.(*contextmenu.ContextMenuDef)
|
||||
if !ok {
|
||||
return nil, ContextMenuGetOutput{}, fmt.Errorf("unexpected result type from context menu get query")
|
||||
return nil, ContextMenuGetOutput{}, coreerr.E("mcp.contextMenuGet", "unexpected result type", nil)
|
||||
}
|
||||
if menu == nil {
|
||||
return nil, ContextMenuGetOutput{}, nil
|
||||
|
|
@ -81,11 +81,11 @@ func (s *Subsystem) contextMenuGet(_ context.Context, _ *mcp.CallToolRequest, in
|
|||
// Convert to map[string]any via JSON round-trip to avoid cyclic type in schema
|
||||
menuJSON, err := json.Marshal(menu)
|
||||
if err != nil {
|
||||
return nil, ContextMenuGetOutput{}, fmt.Errorf("failed to marshal context menu: %w", err)
|
||||
return nil, ContextMenuGetOutput{}, coreerr.E("mcp.contextMenuGet", "failed to marshal context menu", err)
|
||||
}
|
||||
var menuMap map[string]any
|
||||
if err := json.Unmarshal(menuJSON, &menuMap); err != nil {
|
||||
return nil, ContextMenuGetOutput{}, fmt.Errorf("failed to unmarshal context menu: %w", err)
|
||||
return nil, ContextMenuGetOutput{}, coreerr.E("mcp.contextMenuGet", "failed to unmarshal context menu", err)
|
||||
}
|
||||
return nil, ContextMenuGetOutput{Menu: menuMap}, nil
|
||||
}
|
||||
|
|
@ -104,16 +104,16 @@ func (s *Subsystem) contextMenuList(_ context.Context, _ *mcp.CallToolRequest, _
|
|||
}
|
||||
menus, ok := result.(map[string]contextmenu.ContextMenuDef)
|
||||
if !ok {
|
||||
return nil, ContextMenuListOutput{}, fmt.Errorf("unexpected result type from context menu list query")
|
||||
return nil, ContextMenuListOutput{}, coreerr.E("mcp.contextMenuList", "unexpected result type", nil)
|
||||
}
|
||||
// Convert to map[string]any via JSON round-trip to avoid cyclic type in schema
|
||||
menusJSON, err := json.Marshal(menus)
|
||||
if err != nil {
|
||||
return nil, ContextMenuListOutput{}, fmt.Errorf("failed to marshal context menus: %w", err)
|
||||
return nil, ContextMenuListOutput{}, coreerr.E("mcp.contextMenuList", "failed to marshal context menus", err)
|
||||
}
|
||||
var menusMap map[string]any
|
||||
if err := json.Unmarshal(menusJSON, &menusMap); err != nil {
|
||||
return nil, ContextMenuListOutput{}, fmt.Errorf("failed to unmarshal context menus: %w", err)
|
||||
return nil, ContextMenuListOutput{}, coreerr.E("mcp.contextMenuList", "failed to unmarshal context menus", err)
|
||||
}
|
||||
return nil, ContextMenuListOutput{Menus: menusMap}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ package mcp
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"forge.lthn.ai/core/gui/pkg/dialog"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
|
@ -22,7 +22,7 @@ type DialogOpenFileOutput struct {
|
|||
}
|
||||
|
||||
func (s *Subsystem) dialogOpenFile(_ context.Context, _ *mcp.CallToolRequest, input DialogOpenFileInput) (*mcp.CallToolResult, DialogOpenFileOutput, error) {
|
||||
result, _, err := s.core.PERFORM(dialog.TaskOpenFile{Opts: dialog.OpenFileOptions{
|
||||
result, _, err := s.core.PERFORM(dialog.TaskOpenFile{Options: dialog.OpenFileOptions{
|
||||
Title: input.Title,
|
||||
Directory: input.Directory,
|
||||
Filters: input.Filters,
|
||||
|
|
@ -33,7 +33,7 @@ func (s *Subsystem) dialogOpenFile(_ context.Context, _ *mcp.CallToolRequest, in
|
|||
}
|
||||
paths, ok := result.([]string)
|
||||
if !ok {
|
||||
return nil, DialogOpenFileOutput{}, fmt.Errorf("unexpected result type from open file dialog")
|
||||
return nil, DialogOpenFileOutput{}, coreerr.E("mcp.dialogOpenFile", "unexpected result type", nil)
|
||||
}
|
||||
return nil, DialogOpenFileOutput{Paths: paths}, nil
|
||||
}
|
||||
|
|
@ -51,7 +51,7 @@ type DialogSaveFileOutput struct {
|
|||
}
|
||||
|
||||
func (s *Subsystem) dialogSaveFile(_ context.Context, _ *mcp.CallToolRequest, input DialogSaveFileInput) (*mcp.CallToolResult, DialogSaveFileOutput, error) {
|
||||
result, _, err := s.core.PERFORM(dialog.TaskSaveFile{Opts: dialog.SaveFileOptions{
|
||||
result, _, err := s.core.PERFORM(dialog.TaskSaveFile{Options: dialog.SaveFileOptions{
|
||||
Title: input.Title,
|
||||
Directory: input.Directory,
|
||||
Filename: input.Filename,
|
||||
|
|
@ -62,7 +62,7 @@ func (s *Subsystem) dialogSaveFile(_ context.Context, _ *mcp.CallToolRequest, in
|
|||
}
|
||||
path, ok := result.(string)
|
||||
if !ok {
|
||||
return nil, DialogSaveFileOutput{}, fmt.Errorf("unexpected result type from save file dialog")
|
||||
return nil, DialogSaveFileOutput{}, coreerr.E("mcp.dialogSaveFile", "unexpected result type", nil)
|
||||
}
|
||||
return nil, DialogSaveFileOutput{Path: path}, nil
|
||||
}
|
||||
|
|
@ -78,7 +78,7 @@ type DialogOpenDirectoryOutput struct {
|
|||
}
|
||||
|
||||
func (s *Subsystem) dialogOpenDirectory(_ context.Context, _ *mcp.CallToolRequest, input DialogOpenDirectoryInput) (*mcp.CallToolResult, DialogOpenDirectoryOutput, error) {
|
||||
result, _, err := s.core.PERFORM(dialog.TaskOpenDirectory{Opts: dialog.OpenDirectoryOptions{
|
||||
result, _, err := s.core.PERFORM(dialog.TaskOpenDirectory{Options: dialog.OpenDirectoryOptions{
|
||||
Title: input.Title,
|
||||
Directory: input.Directory,
|
||||
}})
|
||||
|
|
@ -87,7 +87,7 @@ func (s *Subsystem) dialogOpenDirectory(_ context.Context, _ *mcp.CallToolReques
|
|||
}
|
||||
path, ok := result.(string)
|
||||
if !ok {
|
||||
return nil, DialogOpenDirectoryOutput{}, fmt.Errorf("unexpected result type from open directory dialog")
|
||||
return nil, DialogOpenDirectoryOutput{}, coreerr.E("mcp.dialogOpenDirectory", "unexpected result type", nil)
|
||||
}
|
||||
return nil, DialogOpenDirectoryOutput{Path: path}, nil
|
||||
}
|
||||
|
|
@ -104,7 +104,7 @@ type DialogConfirmOutput struct {
|
|||
}
|
||||
|
||||
func (s *Subsystem) dialogConfirm(_ context.Context, _ *mcp.CallToolRequest, input DialogConfirmInput) (*mcp.CallToolResult, DialogConfirmOutput, error) {
|
||||
result, _, err := s.core.PERFORM(dialog.TaskMessageDialog{Opts: dialog.MessageDialogOptions{
|
||||
result, _, err := s.core.PERFORM(dialog.TaskMessageDialog{Options: dialog.MessageDialogOptions{
|
||||
Type: dialog.DialogQuestion,
|
||||
Title: input.Title,
|
||||
Message: input.Message,
|
||||
|
|
@ -115,7 +115,7 @@ func (s *Subsystem) dialogConfirm(_ context.Context, _ *mcp.CallToolRequest, inp
|
|||
}
|
||||
button, ok := result.(string)
|
||||
if !ok {
|
||||
return nil, DialogConfirmOutput{}, fmt.Errorf("unexpected result type from confirm dialog")
|
||||
return nil, DialogConfirmOutput{}, coreerr.E("mcp.dialogConfirm", "unexpected result type", nil)
|
||||
}
|
||||
return nil, DialogConfirmOutput{Button: button}, nil
|
||||
}
|
||||
|
|
@ -131,7 +131,7 @@ type DialogPromptOutput struct {
|
|||
}
|
||||
|
||||
func (s *Subsystem) dialogPrompt(_ context.Context, _ *mcp.CallToolRequest, input DialogPromptInput) (*mcp.CallToolResult, DialogPromptOutput, error) {
|
||||
result, _, err := s.core.PERFORM(dialog.TaskMessageDialog{Opts: dialog.MessageDialogOptions{
|
||||
result, _, err := s.core.PERFORM(dialog.TaskMessageDialog{Options: dialog.MessageDialogOptions{
|
||||
Type: dialog.DialogInfo,
|
||||
Title: input.Title,
|
||||
Message: input.Message,
|
||||
|
|
@ -142,7 +142,7 @@ func (s *Subsystem) dialogPrompt(_ context.Context, _ *mcp.CallToolRequest, inpu
|
|||
}
|
||||
button, ok := result.(string)
|
||||
if !ok {
|
||||
return nil, DialogPromptOutput{}, fmt.Errorf("unexpected result type from prompt dialog")
|
||||
return nil, DialogPromptOutput{}, coreerr.E("mcp.dialogPrompt", "unexpected result type", nil)
|
||||
}
|
||||
return nil, DialogPromptOutput{Button: button}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ package mcp
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"forge.lthn.ai/core/gui/pkg/environment"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
|
@ -23,7 +23,7 @@ func (s *Subsystem) themeGet(_ context.Context, _ *mcp.CallToolRequest, _ ThemeG
|
|||
}
|
||||
theme, ok := result.(environment.ThemeInfo)
|
||||
if !ok {
|
||||
return nil, ThemeGetOutput{}, fmt.Errorf("unexpected result type from theme query")
|
||||
return nil, ThemeGetOutput{}, coreerr.E("mcp.themeGet", "unexpected result type", nil)
|
||||
}
|
||||
return nil, ThemeGetOutput{Theme: theme}, nil
|
||||
}
|
||||
|
|
@ -42,7 +42,7 @@ func (s *Subsystem) themeSystem(_ context.Context, _ *mcp.CallToolRequest, _ The
|
|||
}
|
||||
info, ok := result.(environment.EnvironmentInfo)
|
||||
if !ok {
|
||||
return nil, ThemeSystemOutput{}, fmt.Errorf("unexpected result type from environment info query")
|
||||
return nil, ThemeSystemOutput{}, coreerr.E("mcp.themeSystem", "unexpected result type", nil)
|
||||
}
|
||||
return nil, ThemeSystemOutput{Info: info}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ package mcp
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"forge.lthn.ai/core/gui/pkg/window"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
|
@ -57,7 +57,7 @@ func (s *Subsystem) layoutList(_ context.Context, _ *mcp.CallToolRequest, _ Layo
|
|||
}
|
||||
layouts, ok := result.([]window.LayoutInfo)
|
||||
if !ok {
|
||||
return nil, LayoutListOutput{}, fmt.Errorf("unexpected result type from layout list query")
|
||||
return nil, LayoutListOutput{}, coreerr.E("mcp.layoutList", "unexpected result type", nil)
|
||||
}
|
||||
return nil, LayoutListOutput{Layouts: layouts}, nil
|
||||
}
|
||||
|
|
@ -95,7 +95,7 @@ func (s *Subsystem) layoutGet(_ context.Context, _ *mcp.CallToolRequest, input L
|
|||
}
|
||||
layout, ok := result.(*window.Layout)
|
||||
if !ok {
|
||||
return nil, LayoutGetOutput{}, fmt.Errorf("unexpected result type from layout get query")
|
||||
return nil, LayoutGetOutput{}, coreerr.E("mcp.layoutGet", "unexpected result type", nil)
|
||||
}
|
||||
return nil, LayoutGetOutput{Layout: layout}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ type NotificationShowOutput struct {
|
|||
}
|
||||
|
||||
func (s *Subsystem) notificationShow(_ context.Context, _ *mcp.CallToolRequest, input NotificationShowInput) (*mcp.CallToolResult, NotificationShowOutput, error) {
|
||||
_, _, err := s.core.PERFORM(notification.TaskSend{Opts: notification.NotificationOptions{
|
||||
_, _, err := s.core.PERFORM(notification.TaskSend{Options: notification.NotificationOptions{
|
||||
Title: input.Title,
|
||||
Message: input.Message,
|
||||
Subtitle: input.Subtitle,
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ package mcp
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"forge.lthn.ai/core/gui/pkg/screen"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
|
@ -23,7 +23,7 @@ func (s *Subsystem) screenList(_ context.Context, _ *mcp.CallToolRequest, _ Scre
|
|||
}
|
||||
screens, ok := result.([]screen.Screen)
|
||||
if !ok {
|
||||
return nil, ScreenListOutput{}, fmt.Errorf("unexpected result type from screen list query")
|
||||
return nil, ScreenListOutput{}, coreerr.E("mcp.screenList", "unexpected result type", nil)
|
||||
}
|
||||
return nil, ScreenListOutput{Screens: screens}, nil
|
||||
}
|
||||
|
|
@ -44,7 +44,7 @@ func (s *Subsystem) screenGet(_ context.Context, _ *mcp.CallToolRequest, input S
|
|||
}
|
||||
scr, ok := result.(*screen.Screen)
|
||||
if !ok {
|
||||
return nil, ScreenGetOutput{}, fmt.Errorf("unexpected result type from screen get query")
|
||||
return nil, ScreenGetOutput{}, coreerr.E("mcp.screenGet", "unexpected result type", nil)
|
||||
}
|
||||
return nil, ScreenGetOutput{Screen: scr}, nil
|
||||
}
|
||||
|
|
@ -63,7 +63,7 @@ func (s *Subsystem) screenPrimary(_ context.Context, _ *mcp.CallToolRequest, _ S
|
|||
}
|
||||
scr, ok := result.(*screen.Screen)
|
||||
if !ok {
|
||||
return nil, ScreenPrimaryOutput{}, fmt.Errorf("unexpected result type from screen primary query")
|
||||
return nil, ScreenPrimaryOutput{}, coreerr.E("mcp.screenPrimary", "unexpected result type", nil)
|
||||
}
|
||||
return nil, ScreenPrimaryOutput{Screen: scr}, nil
|
||||
}
|
||||
|
|
@ -85,7 +85,7 @@ func (s *Subsystem) screenAtPoint(_ context.Context, _ *mcp.CallToolRequest, inp
|
|||
}
|
||||
scr, ok := result.(*screen.Screen)
|
||||
if !ok {
|
||||
return nil, ScreenAtPointOutput{}, fmt.Errorf("unexpected result type from screen at point query")
|
||||
return nil, ScreenAtPointOutput{}, coreerr.E("mcp.screenAtPoint", "unexpected result type", nil)
|
||||
}
|
||||
return nil, ScreenAtPointOutput{Screen: scr}, nil
|
||||
}
|
||||
|
|
@ -104,7 +104,7 @@ func (s *Subsystem) screenWorkAreas(_ context.Context, _ *mcp.CallToolRequest, _
|
|||
}
|
||||
areas, ok := result.([]screen.Rect)
|
||||
if !ok {
|
||||
return nil, ScreenWorkAreasOutput{}, fmt.Errorf("unexpected result type from screen work areas query")
|
||||
return nil, ScreenWorkAreasOutput{}, coreerr.E("mcp.screenWorkAreas", "unexpected result type", nil)
|
||||
}
|
||||
return nil, ScreenWorkAreasOutput{WorkAreas: areas}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ package mcp
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"forge.lthn.ai/core/gui/pkg/systray"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
|
@ -70,7 +70,7 @@ func (s *Subsystem) trayInfo(_ context.Context, _ *mcp.CallToolRequest, _ TrayIn
|
|||
}
|
||||
config, ok := result.(map[string]any)
|
||||
if !ok {
|
||||
return nil, TrayInfoOutput{}, fmt.Errorf("unexpected result type from tray config query")
|
||||
return nil, TrayInfoOutput{}, coreerr.E("mcp.trayInfo", "unexpected result type", nil)
|
||||
}
|
||||
return nil, TrayInfoOutput{Config: config}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ package mcp
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
"forge.lthn.ai/core/gui/pkg/webview"
|
||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||
)
|
||||
|
|
@ -105,7 +105,7 @@ func (s *Subsystem) webviewScreenshot(_ context.Context, _ *mcp.CallToolRequest,
|
|||
}
|
||||
sr, ok := result.(webview.ScreenshotResult)
|
||||
if !ok {
|
||||
return nil, WebviewScreenshotOutput{}, fmt.Errorf("unexpected result type from webview screenshot")
|
||||
return nil, WebviewScreenshotOutput{}, coreerr.E("mcp.webviewScreenshot", "unexpected result type", nil)
|
||||
}
|
||||
return nil, WebviewScreenshotOutput{Base64: sr.Base64, MimeType: sr.MimeType}, nil
|
||||
}
|
||||
|
|
@ -248,7 +248,7 @@ func (s *Subsystem) webviewConsole(_ context.Context, _ *mcp.CallToolRequest, in
|
|||
}
|
||||
msgs, ok := result.([]webview.ConsoleMessage)
|
||||
if !ok {
|
||||
return nil, WebviewConsoleOutput{}, fmt.Errorf("unexpected result type from webview console query")
|
||||
return nil, WebviewConsoleOutput{}, coreerr.E("mcp.webviewConsole", "unexpected result type", nil)
|
||||
}
|
||||
return nil, WebviewConsoleOutput{Messages: msgs}, nil
|
||||
}
|
||||
|
|
@ -289,7 +289,7 @@ func (s *Subsystem) webviewQuery(_ context.Context, _ *mcp.CallToolRequest, inpu
|
|||
}
|
||||
el, ok := result.(*webview.ElementInfo)
|
||||
if !ok {
|
||||
return nil, WebviewQueryOutput{}, fmt.Errorf("unexpected result type from webview query")
|
||||
return nil, WebviewQueryOutput{}, coreerr.E("mcp.webviewQuery", "unexpected result type", nil)
|
||||
}
|
||||
return nil, WebviewQueryOutput{Element: el}, nil
|
||||
}
|
||||
|
|
@ -312,7 +312,7 @@ func (s *Subsystem) webviewQueryAll(_ context.Context, _ *mcp.CallToolRequest, i
|
|||
}
|
||||
els, ok := result.([]*webview.ElementInfo)
|
||||
if !ok {
|
||||
return nil, WebviewQueryAllOutput{}, fmt.Errorf("unexpected result type from webview query all")
|
||||
return nil, WebviewQueryAllOutput{}, coreerr.E("mcp.webviewQueryAll", "unexpected result type", nil)
|
||||
}
|
||||
return nil, WebviewQueryAllOutput{Elements: els}, nil
|
||||
}
|
||||
|
|
@ -335,7 +335,7 @@ func (s *Subsystem) webviewDOMTree(_ context.Context, _ *mcp.CallToolRequest, in
|
|||
}
|
||||
html, ok := result.(string)
|
||||
if !ok {
|
||||
return nil, WebviewDOMTreeOutput{}, fmt.Errorf("unexpected result type from webview DOM tree query")
|
||||
return nil, WebviewDOMTreeOutput{}, coreerr.E("mcp.webviewDOMTree", "unexpected result type", nil)
|
||||
}
|
||||
return nil, WebviewDOMTreeOutput{HTML: html}, nil
|
||||
}
|
||||
|
|
@ -357,7 +357,7 @@ func (s *Subsystem) webviewURL(_ context.Context, _ *mcp.CallToolRequest, input
|
|||
}
|
||||
url, ok := result.(string)
|
||||
if !ok {
|
||||
return nil, WebviewURLOutput{}, fmt.Errorf("unexpected result type from webview URL query")
|
||||
return nil, WebviewURLOutput{}, coreerr.E("mcp.webviewURL", "unexpected result type", nil)
|
||||
}
|
||||
return nil, WebviewURLOutput{URL: url}, nil
|
||||
}
|
||||
|
|
@ -379,7 +379,7 @@ func (s *Subsystem) webviewTitle(_ context.Context, _ *mcp.CallToolRequest, inpu
|
|||
}
|
||||
title, ok := result.(string)
|
||||
if !ok {
|
||||
return nil, WebviewTitleOutput{}, fmt.Errorf("unexpected result type from webview title query")
|
||||
return nil, WebviewTitleOutput{}, coreerr.E("mcp.webviewTitle", "unexpected result type", nil)
|
||||
}
|
||||
return nil, WebviewTitleOutput{Title: title}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,22 +89,22 @@ type WindowCreateOutput struct {
|
|||
}
|
||||
|
||||
func (s *Subsystem) windowCreate(_ context.Context, _ *mcp.CallToolRequest, input WindowCreateInput) (*mcp.CallToolResult, WindowCreateOutput, error) {
|
||||
opts := []window.WindowOption{
|
||||
options := []window.WindowOption{
|
||||
window.WithName(input.Name),
|
||||
}
|
||||
if input.Title != "" {
|
||||
opts = append(opts, window.WithTitle(input.Title))
|
||||
options = append(options, window.WithTitle(input.Title))
|
||||
}
|
||||
if input.URL != "" {
|
||||
opts = append(opts, window.WithURL(input.URL))
|
||||
options = append(options, window.WithURL(input.URL))
|
||||
}
|
||||
if input.Width > 0 || input.Height > 0 {
|
||||
opts = append(opts, window.WithSize(input.Width, input.Height))
|
||||
options = append(options, window.WithSize(input.Width, input.Height))
|
||||
}
|
||||
if input.X != 0 || input.Y != 0 {
|
||||
opts = append(opts, window.WithPosition(input.X, input.Y))
|
||||
options = append(options, window.WithPosition(input.X, input.Y))
|
||||
}
|
||||
result, _, err := s.core.PERFORM(window.TaskOpenWindow{Opts: opts})
|
||||
result, _, err := s.core.PERFORM(window.TaskOpenWindow{Options: options})
|
||||
if err != nil {
|
||||
return nil, WindowCreateOutput{}, err
|
||||
}
|
||||
|
|
@ -163,7 +163,7 @@ type WindowSizeOutput struct {
|
|||
}
|
||||
|
||||
func (s *Subsystem) windowSize(_ context.Context, _ *mcp.CallToolRequest, input WindowSizeInput) (*mcp.CallToolResult, WindowSizeOutput, error) {
|
||||
_, _, err := s.core.PERFORM(window.TaskSetSize{Name: input.Name, W: input.Width, H: input.Height})
|
||||
_, _, err := s.core.PERFORM(window.TaskSetSize{Name: input.Name, Width: input.Width, Height: input.Height})
|
||||
if err != nil {
|
||||
return nil, WindowSizeOutput{}, err
|
||||
}
|
||||
|
|
@ -188,7 +188,7 @@ func (s *Subsystem) windowBounds(_ context.Context, _ *mcp.CallToolRequest, inpu
|
|||
if err != nil {
|
||||
return nil, WindowBoundsOutput{}, err
|
||||
}
|
||||
_, _, err = s.core.PERFORM(window.TaskSetSize{Name: input.Name, W: input.Width, H: input.Height})
|
||||
_, _, err = s.core.PERFORM(window.TaskSetSize{Name: input.Name, Width: input.Width, Height: input.Height})
|
||||
if err != nil {
|
||||
return nil, WindowBoundsOutput{}, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,9 @@
|
|||
package menu
|
||||
|
||||
// QueryConfig requests this service's config section from the display orchestrator.
|
||||
// Result: map[string]any
|
||||
type QueryConfig struct{}
|
||||
|
||||
// QueryGetAppMenu returns the current app menu item descriptors.
|
||||
// Result: []MenuItem
|
||||
type QueryGetAppMenu struct{}
|
||||
|
||||
// TaskSetAppMenu sets the application menu. OnClick closures work because
|
||||
// core/go IPC is in-process (no serialisation boundary).
|
||||
type TaskSetAppMenu struct{ Items []MenuItem }
|
||||
|
||||
// TaskSaveConfig persists this service's config section via the display orchestrator.
|
||||
type TaskSaveConfig struct{ Value map[string]any }
|
||||
type TaskSaveConfig struct{ Config map[string]any }
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package menu
|
|||
|
||||
import "forge.lthn.ai/core/go/pkg/core"
|
||||
|
||||
// Register creates a factory closure that captures the Platform adapter.
|
||||
func Register(p Platform) func(*core.Core) (any, error) {
|
||||
return func(c *core.Core) (any, error) {
|
||||
return &Service{
|
||||
|
|
|
|||
|
|
@ -6,24 +6,21 @@ import (
|
|||
"forge.lthn.ai/core/go/pkg/core"
|
||||
)
|
||||
|
||||
// Options holds configuration for the menu service.
|
||||
type Options struct{}
|
||||
|
||||
// Service is a core.Service managing application menus via IPC.
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
manager *Manager
|
||||
platform Platform
|
||||
items []MenuItem // last-set menu items for QueryGetAppMenu
|
||||
menuItems []MenuItem
|
||||
showDevTools bool
|
||||
}
|
||||
|
||||
// OnStartup queries config and registers IPC handlers.
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
cfg, handled, _ := s.Core().QUERY(QueryConfig{})
|
||||
configValue, handled, _ := s.Core().QUERY(QueryConfig{})
|
||||
if handled {
|
||||
if mCfg, ok := cfg.(map[string]any); ok {
|
||||
s.applyConfig(mCfg)
|
||||
if menuConfig, ok := configValue.(map[string]any); ok {
|
||||
s.applyConfig(menuConfig)
|
||||
}
|
||||
}
|
||||
s.Core().RegisterQuery(s.handleQuery)
|
||||
|
|
@ -31,20 +28,18 @@ func (s *Service) OnStartup(ctx context.Context) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) applyConfig(cfg map[string]any) {
|
||||
if v, ok := cfg["show_dev_tools"]; ok {
|
||||
func (s *Service) applyConfig(configData map[string]any) {
|
||||
if v, ok := configData["show_dev_tools"]; ok {
|
||||
if show, ok := v.(bool); ok {
|
||||
s.showDevTools = show
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ShowDevTools returns whether developer tools menu items should be shown.
|
||||
func (s *Service) ShowDevTools() bool {
|
||||
return s.showDevTools
|
||||
}
|
||||
|
||||
// HandleIPCEvents is auto-discovered and registered by core.WithService.
|
||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -52,7 +47,7 @@ func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
|||
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
||||
switch q.(type) {
|
||||
case QueryGetAppMenu:
|
||||
return s.items, true, nil
|
||||
return s.menuItems, true, nil
|
||||
default:
|
||||
return nil, false, nil
|
||||
}
|
||||
|
|
@ -61,7 +56,7 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
|||
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
||||
switch t := t.(type) {
|
||||
case TaskSetAppMenu:
|
||||
s.items = t.Items
|
||||
s.menuItems = t.Items
|
||||
s.manager.SetApplicationMenu(t.Items)
|
||||
return nil, true, nil
|
||||
default:
|
||||
|
|
@ -69,7 +64,6 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
|||
}
|
||||
}
|
||||
|
||||
// Manager returns the underlying menu Manager.
|
||||
func (s *Service) Manager() *Manager {
|
||||
return s.manager
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,9 @@
|
|||
// pkg/notification/messages.go
|
||||
package notification
|
||||
|
||||
// QueryPermission checks notification authorisation. Result: PermissionStatus
|
||||
type QueryPermission struct{}
|
||||
|
||||
// TaskSend sends a notification. Falls back to dialog if platform fails.
|
||||
type TaskSend struct{ Opts NotificationOptions }
|
||||
type TaskSend struct{ Options NotificationOptions }
|
||||
|
||||
// TaskRequestPermission requests notification authorisation. Result: bool (granted)
|
||||
type TaskRequestPermission struct{}
|
||||
|
||||
// ActionNotificationClicked is broadcast when a notification is clicked (future).
|
||||
type ActionNotificationClicked struct{ ID string }
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package notification
|
|||
|
||||
// Platform abstracts the native notification backend.
|
||||
type Platform interface {
|
||||
Send(opts NotificationOptions) error
|
||||
Send(options NotificationOptions) error
|
||||
RequestPermission() (bool, error)
|
||||
CheckPermission() (bool, error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,23 +3,20 @@ package notification
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
"forge.lthn.ai/core/gui/pkg/dialog"
|
||||
)
|
||||
|
||||
// Options holds configuration for the notification service.
|
||||
type Options struct{}
|
||||
|
||||
// Service is a core.Service managing notifications via IPC.
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
platform Platform
|
||||
}
|
||||
|
||||
// Register creates a factory closure that captures the Platform adapter.
|
||||
func Register(p Platform) func(*core.Core) (any, error) {
|
||||
return func(c *core.Core) (any, error) {
|
||||
return &Service{
|
||||
|
|
@ -29,14 +26,12 @@ func Register(p Platform) func(*core.Core) (any, error) {
|
|||
}
|
||||
}
|
||||
|
||||
// OnStartup registers IPC handlers.
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
s.Core().RegisterQuery(s.handleQuery)
|
||||
s.Core().RegisterTask(s.handleTask)
|
||||
return nil
|
||||
}
|
||||
|
||||
// HandleIPCEvents is auto-discovered by core.WithService.
|
||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -54,7 +49,7 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
|||
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
||||
switch t := t.(type) {
|
||||
case TaskSend:
|
||||
return nil, true, s.send(t.Opts)
|
||||
return nil, true, s.send(t.Options)
|
||||
case TaskRequestPermission:
|
||||
granted, err := s.platform.RequestPermission()
|
||||
return granted, true, err
|
||||
|
|
@ -64,24 +59,24 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
|||
}
|
||||
|
||||
// send attempts native notification, falls back to dialog via IPC.
|
||||
func (s *Service) send(opts NotificationOptions) error {
|
||||
func (s *Service) send(options NotificationOptions) error {
|
||||
// Generate ID if not provided
|
||||
if opts.ID == "" {
|
||||
opts.ID = fmt.Sprintf("core-%d", time.Now().UnixNano())
|
||||
if options.ID == "" {
|
||||
options.ID = "core-" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
}
|
||||
|
||||
if err := s.platform.Send(opts); err != nil {
|
||||
if err := s.platform.Send(options); err != nil {
|
||||
// Fallback: show as dialog via IPC
|
||||
return s.fallbackDialog(opts)
|
||||
return s.fallbackDialog(options)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fallbackDialog shows a dialog via IPC when native notifications fail.
|
||||
func (s *Service) fallbackDialog(opts NotificationOptions) error {
|
||||
func (s *Service) fallbackDialog(options NotificationOptions) error {
|
||||
// Map severity to dialog type
|
||||
var dt dialog.DialogType
|
||||
switch opts.Severity {
|
||||
switch options.Severity {
|
||||
case SeverityWarning:
|
||||
dt = dialog.DialogWarning
|
||||
case SeverityError:
|
||||
|
|
@ -90,15 +85,15 @@ func (s *Service) fallbackDialog(opts NotificationOptions) error {
|
|||
dt = dialog.DialogInfo
|
||||
}
|
||||
|
||||
msg := opts.Message
|
||||
if opts.Subtitle != "" {
|
||||
msg = opts.Subtitle + "\n\n" + msg
|
||||
msg := options.Message
|
||||
if options.Subtitle != "" {
|
||||
msg = options.Subtitle + "\n\n" + msg
|
||||
}
|
||||
|
||||
_, _, err := s.Core().PERFORM(dialog.TaskMessageDialog{
|
||||
Opts: dialog.MessageDialogOptions{
|
||||
Options: dialog.MessageDialogOptions{
|
||||
Type: dt,
|
||||
Title: opts.Title,
|
||||
Title: options.Title,
|
||||
Message: msg,
|
||||
Buttons: []string{"OK"},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ func TestRegister_Good(t *testing.T) {
|
|||
func TestTaskSend_Good(t *testing.T) {
|
||||
mock, c := newTestService(t)
|
||||
_, handled, err := c.PERFORM(TaskSend{
|
||||
Opts: NotificationOptions{Title: "Test", Message: "Hello"},
|
||||
Options: NotificationOptions{Title: "Test", Message: "Hello"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
|
@ -87,7 +87,7 @@ func TestTaskSend_Fallback_Good(t *testing.T) {
|
|||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
||||
|
||||
_, handled, err := c.PERFORM(TaskSend{
|
||||
Opts: NotificationOptions{Title: "Warn", Message: "Oops", Severity: SeverityWarning},
|
||||
Options: NotificationOptions{Title: "Warn", Message: "Oops", Severity: SeverityWarning},
|
||||
})
|
||||
assert.True(t, handled)
|
||||
assert.NoError(t, err) // fallback succeeds even though platform failed
|
||||
|
|
|
|||
|
|
@ -22,10 +22,8 @@ func (m *Manager) buildMenu(items []TrayMenuItem) PlatformMenu {
|
|||
continue
|
||||
}
|
||||
if len(item.Submenu) > 0 {
|
||||
sub := m.buildMenu(item.Submenu)
|
||||
mi := menu.Add(item.Label)
|
||||
_ = mi.AddSubmenu()
|
||||
_ = sub // TODO: wire sub into parent via platform
|
||||
sub := menu.AddSubmenu(item.Label)
|
||||
m.buildMenu(sub, item.Submenu)
|
||||
continue
|
||||
}
|
||||
mi := menu.Add(item.Label)
|
||||
|
|
|
|||
|
|
@ -1,30 +1,17 @@
|
|||
package systray
|
||||
|
||||
// QueryConfig requests this service's config section from the display orchestrator.
|
||||
// Result: map[string]any
|
||||
type QueryConfig struct{}
|
||||
|
||||
// --- Tasks ---
|
||||
|
||||
// TaskSetTrayIcon sets the tray icon.
|
||||
type TaskSetTrayIcon struct{ Data []byte }
|
||||
|
||||
// TaskSetTrayMenu sets the tray menu items.
|
||||
type TaskSetTrayMenu struct{ Items []TrayMenuItem }
|
||||
|
||||
// TaskShowPanel shows the tray panel window.
|
||||
type TaskShowPanel struct{}
|
||||
|
||||
// TaskHidePanel hides the tray panel window.
|
||||
type TaskHidePanel struct{}
|
||||
|
||||
// TaskSaveConfig persists this service's config section via the display orchestrator.
|
||||
type TaskSaveConfig struct{ Value map[string]any }
|
||||
type TaskSaveConfig struct{ Config map[string]any }
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
// ActionTrayClicked is broadcast when the tray icon is clicked.
|
||||
type ActionTrayClicked struct{}
|
||||
|
||||
// ActionTrayMenuItemClicked is broadcast when a tray menu item is clicked.
|
||||
type ActionTrayMenuItemClicked struct{ ActionID string }
|
||||
|
|
|
|||
|
|
@ -13,14 +13,17 @@ 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) {}
|
||||
|
||||
type exportedMockMenu struct{ items []exportedMockMenuItem }
|
||||
type exportedMockMenu struct {
|
||||
items []exportedMockMenuItem
|
||||
subs []*exportedMockMenu
|
||||
}
|
||||
|
||||
func (m *exportedMockMenu) Add(label string) PlatformMenuItem {
|
||||
mi := &exportedMockMenuItem{label: label}
|
||||
|
|
@ -28,15 +31,20 @@ func (m *exportedMockMenu) Add(label string) PlatformMenuItem {
|
|||
return mi
|
||||
}
|
||||
func (m *exportedMockMenu) AddSeparator() {}
|
||||
func (m *exportedMockMenu) AddSubmenu(label string) PlatformMenu {
|
||||
m.items = append(m.items, exportedMockMenuItem{label: label})
|
||||
sub := &exportedMockMenu{}
|
||||
m.subs = append(m.subs, sub)
|
||||
return sub
|
||||
}
|
||||
|
||||
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 }
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ func (p *mockPlatform) NewMenu() PlatformMenu {
|
|||
|
||||
type mockTrayMenu struct {
|
||||
items []string
|
||||
subs []*mockTrayMenu
|
||||
}
|
||||
|
||||
func (m *mockTrayMenu) Add(label string) PlatformMenuItem {
|
||||
|
|
@ -29,14 +30,19 @@ func (m *mockTrayMenu) Add(label string) PlatformMenuItem {
|
|||
return &mockTrayMenuItem{}
|
||||
}
|
||||
func (m *mockTrayMenu) AddSeparator() { m.items = append(m.items, "---") }
|
||||
func (m *mockTrayMenu) AddSubmenu(label string) PlatformMenu {
|
||||
m.items = append(m.items, label)
|
||||
sub := &mockTrayMenu{}
|
||||
m.subs = append(m.subs, sub)
|
||||
return sub
|
||||
}
|
||||
|
||||
type mockTrayMenuItem struct{}
|
||||
|
||||
func (mi *mockTrayMenuItem) SetTooltip(text string) {}
|
||||
func (mi *mockTrayMenuItem) SetChecked(checked bool) {}
|
||||
func (mi *mockTrayMenuItem) SetEnabled(enabled bool) {}
|
||||
func (mi *mockTrayMenuItem) OnClick(fn func()) {}
|
||||
func (mi *mockTrayMenuItem) AddSubmenu() PlatformMenu { return &mockTrayMenu{} }
|
||||
func (mi *mockTrayMenuItem) SetChecked(checked bool) {}
|
||||
func (mi *mockTrayMenuItem) SetEnabled(enabled bool) {}
|
||||
func (mi *mockTrayMenuItem) OnClick(fn func()) {}
|
||||
|
||||
type mockTray struct {
|
||||
icon, templateIcon []byte
|
||||
|
|
@ -45,9 +51,9 @@ type mockTray struct {
|
|||
attachedWindow WindowHandle
|
||||
}
|
||||
|
||||
func (t *mockTray) SetIcon(data []byte) { t.icon = data }
|
||||
func (t *mockTray) SetIcon(data []byte) { t.icon = data }
|
||||
func (t *mockTray) SetTemplateIcon(data []byte) { t.templateIcon = data }
|
||||
func (t *mockTray) SetTooltip(text string) { t.tooltip = text }
|
||||
func (t *mockTray) SetLabel(text string) { t.label = text }
|
||||
func (t *mockTray) SetMenu(menu PlatformMenu) { t.menu = menu }
|
||||
func (t *mockTray) AttachWindow(w WindowHandle) { t.attachedWindow = w }
|
||||
func (t *mockTray) AttachWindow(w WindowHandle) { t.attachedWindow = w }
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ type PlatformTray interface {
|
|||
type PlatformMenu interface {
|
||||
Add(label string) PlatformMenuItem
|
||||
AddSeparator()
|
||||
AddSubmenu(label string) PlatformMenu
|
||||
}
|
||||
|
||||
// PlatformMenuItem is a single item in a tray menu.
|
||||
|
|
@ -29,7 +30,6 @@ type PlatformMenuItem interface {
|
|||
SetChecked(checked bool)
|
||||
SetEnabled(enabled bool)
|
||||
OnClick(fn func())
|
||||
AddSubmenu() PlatformMenu
|
||||
}
|
||||
|
||||
// WindowHandle is a cross-package interface for window operations.
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package systray
|
|||
|
||||
import "forge.lthn.ai/core/go/pkg/core"
|
||||
|
||||
// Register creates a factory closure that captures the Platform adapter.
|
||||
func Register(p Platform) func(*core.Core) (any, error) {
|
||||
return func(c *core.Core) (any, error) {
|
||||
return &Service{
|
||||
|
|
|
|||
|
|
@ -6,10 +6,8 @@ import (
|
|||
"forge.lthn.ai/core/go/pkg/core"
|
||||
)
|
||||
|
||||
// Options holds configuration for the systray service.
|
||||
type Options struct{}
|
||||
|
||||
// Service is a core.Service managing the system tray via IPC.
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
manager *Manager
|
||||
|
|
@ -17,33 +15,31 @@ type Service struct {
|
|||
iconPath string
|
||||
}
|
||||
|
||||
// OnStartup queries config and registers IPC handlers.
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
cfg, handled, _ := s.Core().QUERY(QueryConfig{})
|
||||
configValue, handled, _ := s.Core().QUERY(QueryConfig{})
|
||||
if handled {
|
||||
if tCfg, ok := cfg.(map[string]any); ok {
|
||||
s.applyConfig(tCfg)
|
||||
if trayConfig, ok := configValue.(map[string]any); ok {
|
||||
s.applyConfig(trayConfig)
|
||||
}
|
||||
}
|
||||
s.Core().RegisterTask(s.handleTask)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) applyConfig(cfg map[string]any) {
|
||||
tooltip, _ := cfg["tooltip"].(string)
|
||||
func (s *Service) applyConfig(configData map[string]any) {
|
||||
tooltip, _ := configData["tooltip"].(string)
|
||||
if tooltip == "" {
|
||||
tooltip = "Core"
|
||||
}
|
||||
_ = s.manager.Setup(tooltip, tooltip)
|
||||
|
||||
if iconPath, ok := cfg["icon"].(string); ok && iconPath != "" {
|
||||
if iconPath, ok := configData["icon"].(string); ok && iconPath != "" {
|
||||
// Icon loading is deferred to when assets are available.
|
||||
// Store the path for later use.
|
||||
s.iconPath = iconPath
|
||||
}
|
||||
}
|
||||
|
||||
// HandleIPCEvents is auto-discovered and registered by core.WithService.
|
||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -78,7 +74,6 @@ func (s *Service) taskSetTrayMenu(t TaskSetTrayMenu) error {
|
|||
return s.manager.SetMenu(t.Items)
|
||||
}
|
||||
|
||||
// Manager returns the underlying systray Manager.
|
||||
func (s *Service) Manager() *Manager {
|
||||
return s.manager
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@ package systray
|
|||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
)
|
||||
|
||||
//go:embed assets/apptray.png
|
||||
|
|
@ -31,7 +32,7 @@ func NewManager(platform Platform) *Manager {
|
|||
func (m *Manager) Setup(tooltip, label string) error {
|
||||
m.tray = m.platform.NewTray()
|
||||
if m.tray == nil {
|
||||
return fmt.Errorf("platform returned nil tray")
|
||||
return coreerr.E("systray.Setup", "platform returned nil tray", nil)
|
||||
}
|
||||
m.tray.SetTemplateIcon(defaultIcon)
|
||||
m.tray.SetTooltip(tooltip)
|
||||
|
|
@ -42,7 +43,7 @@ func (m *Manager) Setup(tooltip, label string) error {
|
|||
// SetIcon sets the tray icon.
|
||||
func (m *Manager) SetIcon(data []byte) error {
|
||||
if m.tray == nil {
|
||||
return fmt.Errorf("tray not initialised")
|
||||
return coreerr.E("systray.SetIcon", "tray not initialised", nil)
|
||||
}
|
||||
m.tray.SetIcon(data)
|
||||
return nil
|
||||
|
|
@ -51,7 +52,7 @@ func (m *Manager) SetIcon(data []byte) error {
|
|||
// SetTemplateIcon sets the template icon (macOS).
|
||||
func (m *Manager) SetTemplateIcon(data []byte) error {
|
||||
if m.tray == nil {
|
||||
return fmt.Errorf("tray not initialised")
|
||||
return coreerr.E("systray.SetTemplateIcon", "tray not initialised", nil)
|
||||
}
|
||||
m.tray.SetTemplateIcon(data)
|
||||
return nil
|
||||
|
|
@ -60,7 +61,7 @@ func (m *Manager) SetTemplateIcon(data []byte) error {
|
|||
// SetTooltip sets the tray tooltip.
|
||||
func (m *Manager) SetTooltip(text string) error {
|
||||
if m.tray == nil {
|
||||
return fmt.Errorf("tray not initialised")
|
||||
return coreerr.E("systray.SetTooltip", "tray not initialised", nil)
|
||||
}
|
||||
m.tray.SetTooltip(text)
|
||||
return nil
|
||||
|
|
@ -69,7 +70,7 @@ func (m *Manager) SetTooltip(text string) error {
|
|||
// SetLabel sets the tray label.
|
||||
func (m *Manager) SetLabel(text string) error {
|
||||
if m.tray == nil {
|
||||
return fmt.Errorf("tray not initialised")
|
||||
return coreerr.E("systray.SetLabel", "tray not initialised", nil)
|
||||
}
|
||||
m.tray.SetLabel(text)
|
||||
return nil
|
||||
|
|
@ -78,7 +79,7 @@ func (m *Manager) SetLabel(text string) error {
|
|||
// AttachWindow attaches a panel window to the tray.
|
||||
func (m *Manager) AttachWindow(w WindowHandle) error {
|
||||
if m.tray == nil {
|
||||
return fmt.Errorf("tray not initialised")
|
||||
return coreerr.E("systray.AttachWindow", "tray not initialised", nil)
|
||||
}
|
||||
m.tray.AttachWindow(w)
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -84,3 +84,26 @@ func TestManager_GetInfo_Good(t *testing.T) {
|
|||
info = m.GetInfo()
|
||||
assert.True(t, info["active"].(bool))
|
||||
}
|
||||
|
||||
func TestManager_Build_Submenu_Recursive_Good(t *testing.T) {
|
||||
m, p := newTestManager()
|
||||
items := []MenuItem{
|
||||
{
|
||||
Label: "Parent",
|
||||
Children: []MenuItem{
|
||||
{Label: "Child 1"},
|
||||
{Label: "Child 2"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
menu := m.Build(items)
|
||||
assert.NotNil(t, menu)
|
||||
require.Len(t, p.menus, 1)
|
||||
require.Len(t, p.menus[0].items, 1)
|
||||
assert.Equal(t, "Parent", p.menus[0].items[0])
|
||||
require.Len(t, p.menus[0].subs, 1)
|
||||
require.Len(t, p.menus[0].subs[0].items, 2)
|
||||
assert.Equal(t, "Child 1", p.menus[0].subs[0].items[0])
|
||||
assert.Equal(t, "Child 2", p.menus[0].subs[0].items[1])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
@ -56,18 +56,18 @@ func (m *wailsTrayMenu) AddSeparator() {
|
|||
m.menu.AddSeparator()
|
||||
}
|
||||
|
||||
func (m *wailsTrayMenu) AddSubmenu(label string) PlatformMenu {
|
||||
return &wailsTrayMenu{menu: m.menu.AddSubmenu(label)}
|
||||
}
|
||||
|
||||
// wailsTrayMenuItem wraps *application.MenuItem for the PlatformMenuItem interface.
|
||||
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()) {
|
||||
mi.item.OnClick(func(ctx *application.Context) { fn() })
|
||||
}
|
||||
func (mi *wailsTrayMenuItem) AddSubmenu() PlatformMenu {
|
||||
// Wails doesn't have a direct AddSubmenu on MenuItem — use Menu.AddSubmenu instead
|
||||
return &wailsTrayMenu{menu: application.NewMenu()}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@
|
|||
package webview
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
|
|
@ -47,7 +47,7 @@ type Options struct {
|
|||
// Service is a core.Service managing webview interactions via IPC.
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
opts Options
|
||||
options Options
|
||||
connections map[string]connector
|
||||
mu sync.RWMutex
|
||||
newConn func(debugURL, windowName string) (connector, error) // injectable for tests
|
||||
|
|
@ -55,19 +55,19 @@ type Service struct {
|
|||
}
|
||||
|
||||
// Register creates a factory closure with the given options.
|
||||
func Register(opts ...func(*Options)) func(*core.Core) (any, error) {
|
||||
func Register(optionFns ...func(*Options)) func(*core.Core) (any, error) {
|
||||
o := Options{
|
||||
DebugURL: "http://localhost:9222",
|
||||
Timeout: 30 * time.Second,
|
||||
ConsoleLimit: 1000,
|
||||
}
|
||||
for _, fn := range opts {
|
||||
for _, fn := range optionFns {
|
||||
fn(&o)
|
||||
}
|
||||
return func(c *core.Core) (any, error) {
|
||||
svc := &Service{
|
||||
ServiceRuntime: core.NewServiceRuntime[Options](c, o),
|
||||
opts: o,
|
||||
options: o,
|
||||
connections: make(map[string]connector),
|
||||
newConn: defaultNewConn(o),
|
||||
}
|
||||
|
|
@ -77,7 +77,7 @@ func Register(opts ...func(*Options)) func(*core.Core) (any, error) {
|
|||
}
|
||||
|
||||
// defaultNewConn creates real go-webview connections.
|
||||
func defaultNewConn(opts Options) func(string, string) (connector, error) {
|
||||
func defaultNewConn(options Options) func(string, string) (connector, error) {
|
||||
return func(debugURL, windowName string) (connector, error) {
|
||||
// Enumerate targets, match by title/URL containing window name
|
||||
targets, err := gowebview.ListTargets(debugURL)
|
||||
|
|
@ -86,7 +86,7 @@ func defaultNewConn(opts Options) func(string, string) (connector, error) {
|
|||
}
|
||||
var wsURL string
|
||||
for _, t := range targets {
|
||||
if t.Type == "page" && (strings.Contains(t.Title, windowName) || strings.Contains(t.URL, windowName)) {
|
||||
if t.Type == "page" && (bytes.Contains([]byte(t.Title), []byte(windowName)) || bytes.Contains([]byte(t.URL), []byte(windowName))) {
|
||||
wsURL = t.WebSocketDebuggerURL
|
||||
break
|
||||
}
|
||||
|
|
@ -105,8 +105,8 @@ func defaultNewConn(opts Options) func(string, string) (connector, error) {
|
|||
}
|
||||
wv, err := gowebview.New(
|
||||
gowebview.WithDebugURL(debugURL),
|
||||
gowebview.WithTimeout(opts.Timeout),
|
||||
gowebview.WithConsoleLimit(opts.ConsoleLimit),
|
||||
gowebview.WithTimeout(options.Timeout),
|
||||
gowebview.WithConsoleLimit(options.ConsoleLimit),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -201,7 +201,7 @@ func (s *Service) getConn(windowName string) (connector, error) {
|
|||
if conn, ok := s.connections[windowName]; ok {
|
||||
return conn, nil
|
||||
}
|
||||
conn, err := s.newConn(s.opts.DebugURL, windowName)
|
||||
conn, err := s.newConn(s.options.DebugURL, windowName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -373,17 +373,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 {
|
||||
|
|
|
|||
|
|
@ -3,11 +3,13 @@ package window
|
|||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
coreerr "forge.lthn.ai/core/go-log"
|
||||
)
|
||||
|
||||
// Layout is a named window arrangement.
|
||||
|
|
@ -65,13 +67,13 @@ func (lm *LayoutManager) load() {
|
|||
if lm.configDir == "" {
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(lm.filePath())
|
||||
content, err := coreio.Local.Read(lm.filePath())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
lm.mu.Lock()
|
||||
defer lm.mu.Unlock()
|
||||
_ = json.Unmarshal(data, &lm.layouts)
|
||||
_ = json.Unmarshal([]byte(content), &lm.layouts)
|
||||
}
|
||||
|
||||
func (lm *LayoutManager) save() {
|
||||
|
|
@ -84,14 +86,14 @@ func (lm *LayoutManager) save() {
|
|||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.MkdirAll(lm.configDir, 0o755)
|
||||
_ = os.WriteFile(lm.filePath(), data, 0o644)
|
||||
_ = coreio.Local.EnsureDir(lm.configDir)
|
||||
_ = coreio.Local.Write(lm.filePath(), string(data))
|
||||
}
|
||||
|
||||
// SaveLayout creates or updates a named layout.
|
||||
func (lm *LayoutManager) SaveLayout(name string, windowStates map[string]WindowState) error {
|
||||
if name == "" {
|
||||
return fmt.Errorf("layout name cannot be empty")
|
||||
return coreerr.E("window.LayoutManager.SaveLayout", "layout name cannot be empty", nil)
|
||||
}
|
||||
now := time.Now().UnixMilli()
|
||||
lm.mu.Lock()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
package window
|
||||
|
||||
// WindowInfo contains information about a window.
|
||||
type WindowInfo struct {
|
||||
Name string `json:"name"`
|
||||
Title string `json:"title"`
|
||||
|
|
@ -12,103 +11,70 @@ type WindowInfo struct {
|
|||
Focused bool `json:"focused"`
|
||||
}
|
||||
|
||||
// --- Queries (read-only) ---
|
||||
|
||||
// QueryWindowList returns all tracked windows. Result: []WindowInfo
|
||||
type QueryWindowList struct{}
|
||||
|
||||
// QueryWindowByName returns a single window by name. Result: *WindowInfo (nil if not found)
|
||||
type QueryWindowByName struct{ Name string }
|
||||
|
||||
// QueryConfig requests this service's config section from the display orchestrator.
|
||||
// Result: map[string]any
|
||||
type QueryConfig struct{}
|
||||
|
||||
// --- Tasks (side-effects) ---
|
||||
type TaskOpenWindow struct{ Options []WindowOption }
|
||||
|
||||
// TaskOpenWindow creates a new window. Result: WindowInfo
|
||||
type TaskOpenWindow struct{ Opts []WindowOption }
|
||||
|
||||
// TaskCloseWindow closes a window. Handler persists state BEFORE emitting ActionWindowClosed.
|
||||
type TaskCloseWindow struct{ Name string }
|
||||
|
||||
// TaskSetPosition moves a window.
|
||||
type TaskSetPosition struct {
|
||||
Name string
|
||||
X, Y int
|
||||
}
|
||||
|
||||
// TaskSetSize resizes a window.
|
||||
type TaskSetSize struct {
|
||||
Name string
|
||||
W, H int
|
||||
Name string
|
||||
Width, Height int
|
||||
}
|
||||
|
||||
// TaskMaximise maximises a window.
|
||||
type TaskMaximise struct{ Name string }
|
||||
|
||||
// TaskMinimise minimises a window.
|
||||
type TaskMinimise struct{ Name string }
|
||||
|
||||
// TaskFocus brings a window to the front.
|
||||
type TaskFocus struct{ Name string }
|
||||
|
||||
// TaskRestore restores a maximised or minimised window to its normal state.
|
||||
type TaskRestore struct{ Name string }
|
||||
|
||||
// TaskSetTitle changes a window's title.
|
||||
type TaskSetTitle struct {
|
||||
Name string
|
||||
Title string
|
||||
}
|
||||
|
||||
// TaskSetVisibility shows or hides a window.
|
||||
type TaskSetVisibility struct {
|
||||
Name string
|
||||
Visible bool
|
||||
}
|
||||
|
||||
// TaskFullscreen enters or exits fullscreen mode.
|
||||
type TaskFullscreen struct {
|
||||
Name string
|
||||
Fullscreen bool
|
||||
}
|
||||
|
||||
// --- Layout Queries ---
|
||||
|
||||
// QueryLayoutList returns summaries of all saved layouts. Result: []LayoutInfo
|
||||
type QueryLayoutList struct{}
|
||||
|
||||
// QueryLayoutGet returns a layout by name. Result: *Layout (nil if not found)
|
||||
type QueryLayoutGet struct{ Name string }
|
||||
|
||||
// --- Layout Tasks ---
|
||||
|
||||
// TaskSaveLayout saves the current window arrangement as a named layout. Result: bool
|
||||
type TaskSaveLayout struct{ Name string }
|
||||
|
||||
// TaskRestoreLayout restores a saved layout by name.
|
||||
type TaskRestoreLayout struct{ Name string }
|
||||
|
||||
// TaskDeleteLayout removes a saved layout by name.
|
||||
type TaskDeleteLayout struct{ Name string }
|
||||
|
||||
// TaskTileWindows arranges windows in a tiling mode.
|
||||
type TaskTileWindows struct {
|
||||
Mode string // "left-right", "grid", "left-half", "right-half", etc.
|
||||
Windows []string // window names; empty = all
|
||||
}
|
||||
|
||||
// TaskSnapWindow snaps a window to a screen edge/corner.
|
||||
type TaskSnapWindow struct {
|
||||
Name string // window name
|
||||
Position string // "left", "right", "top", "bottom", "top-left", "top-right", "bottom-left", "bottom-right", "center"
|
||||
}
|
||||
|
||||
// TaskSaveConfig persists this service's config section via the display orchestrator.
|
||||
type TaskSaveConfig struct{ Value map[string]any }
|
||||
|
||||
// --- Actions (broadcasts) ---
|
||||
type TaskSaveConfig struct{ Config map[string]any }
|
||||
|
||||
type ActionWindowOpened struct{ Name string }
|
||||
type ActionWindowClosed struct{ Name string }
|
||||
|
|
@ -119,15 +85,15 @@ type ActionWindowMoved struct {
|
|||
}
|
||||
|
||||
type ActionWindowResized struct {
|
||||
Name string
|
||||
W, H int
|
||||
Name string
|
||||
Width, Height int
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,11 +10,11 @@ func NewMockPlatform() *MockPlatform {
|
|||
return &MockPlatform{}
|
||||
}
|
||||
|
||||
func (m *MockPlatform) CreateWindow(opts PlatformWindowOptions) PlatformWindow {
|
||||
func (m *MockPlatform) CreateWindow(options PlatformWindowOptions) PlatformWindow {
|
||||
w := &MockWindow{
|
||||
name: opts.Name, title: opts.Title, url: opts.URL,
|
||||
width: opts.Width, height: opts.Height,
|
||||
x: opts.X, y: opts.Y,
|
||||
name: options.Name, title: options.Title, url: options.URL,
|
||||
width: options.Width, height: options.Height,
|
||||
x: options.X, y: options.Y,
|
||||
}
|
||||
m.Windows = append(m.Windows, w)
|
||||
return w
|
||||
|
|
@ -38,28 +38,30 @@ 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) 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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
// pkg/window/mock_test.go
|
||||
package window
|
||||
|
||||
type mockPlatform struct {
|
||||
|
|
@ -9,11 +8,11 @@ func newMockPlatform() *mockPlatform {
|
|||
return &mockPlatform{}
|
||||
}
|
||||
|
||||
func (m *mockPlatform) CreateWindow(opts PlatformWindowOptions) PlatformWindow {
|
||||
func (m *mockPlatform) CreateWindow(options PlatformWindowOptions) PlatformWindow {
|
||||
w := &mockWindow{
|
||||
name: opts.Name, title: opts.Title, url: opts.URL,
|
||||
width: opts.Width, height: opts.Height,
|
||||
x: opts.X, y: opts.Y,
|
||||
name: options.Name, title: options.Title, url: options.URL,
|
||||
width: options.Width, height: options.Height,
|
||||
x: options.X, y: options.Y,
|
||||
}
|
||||
m.windows = append(m.windows, w)
|
||||
return w
|
||||
|
|
@ -33,32 +32,36 @@ type mockWindow struct {
|
|||
maximised, focused bool
|
||||
visible, alwaysOnTop bool
|
||||
closed bool
|
||||
minimised bool
|
||||
fullscreened bool
|
||||
eventHandlers []func(WindowEvent)
|
||||
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() { w.minimised = true }
|
||||
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() { w.fullscreened = true }
|
||||
func (w *mockWindow) UnFullscreen() { w.fullscreened = false }
|
||||
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,13 +5,13 @@ package window
|
|||
type WindowOption func(*Window) error
|
||||
|
||||
// ApplyOptions creates a Window and applies all options in order.
|
||||
func ApplyOptions(opts ...WindowOption) (*Window, error) {
|
||||
func ApplyOptions(options ...WindowOption) (*Window, error) {
|
||||
w := &Window{}
|
||||
for _, opt := range opts {
|
||||
if opt == nil {
|
||||
for _, option := range options {
|
||||
if option == nil {
|
||||
continue
|
||||
}
|
||||
if err := opt(w); err != nil {
|
||||
if err := option(w); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
|
|
|||
334
pkg/window/persistence_test.go
Normal file
334
pkg/window/persistence_test.go
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
// pkg/window/persistence_test.go
|
||||
package window
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// --- StateManager Persistence Tests ---
|
||||
|
||||
func TestStateManager_SetAndGet_Good(t *testing.T) {
|
||||
sm := NewStateManagerWithDir(t.TempDir())
|
||||
state := WindowState{
|
||||
X: 150, Y: 250, Width: 1024, Height: 768,
|
||||
Maximized: true, Screen: "primary", URL: "/app",
|
||||
}
|
||||
sm.SetState("editor", state)
|
||||
|
||||
got, ok := sm.GetState("editor")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, 150, got.X)
|
||||
assert.Equal(t, 250, got.Y)
|
||||
assert.Equal(t, 1024, got.Width)
|
||||
assert.Equal(t, 768, got.Height)
|
||||
assert.True(t, got.Maximized)
|
||||
assert.Equal(t, "primary", got.Screen)
|
||||
assert.Equal(t, "/app", got.URL)
|
||||
assert.NotZero(t, got.UpdatedAt, "UpdatedAt should be set by SetState")
|
||||
}
|
||||
|
||||
func TestStateManager_UpdatePosition_Good(t *testing.T) {
|
||||
sm := NewStateManagerWithDir(t.TempDir())
|
||||
sm.SetState("win", WindowState{X: 0, Y: 0, Width: 800, Height: 600})
|
||||
|
||||
sm.UpdatePosition("win", 300, 400)
|
||||
|
||||
got, ok := sm.GetState("win")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, 300, got.X)
|
||||
assert.Equal(t, 400, got.Y)
|
||||
// Width/Height should remain unchanged
|
||||
assert.Equal(t, 800, got.Width)
|
||||
assert.Equal(t, 600, got.Height)
|
||||
}
|
||||
|
||||
func TestStateManager_UpdateSize_Good(t *testing.T) {
|
||||
sm := NewStateManagerWithDir(t.TempDir())
|
||||
sm.SetState("win", WindowState{X: 100, Y: 200, Width: 800, Height: 600})
|
||||
|
||||
sm.UpdateSize("win", 1920, 1080)
|
||||
|
||||
got, ok := sm.GetState("win")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, 1920, got.Width)
|
||||
assert.Equal(t, 1080, got.Height)
|
||||
// Position should remain unchanged
|
||||
assert.Equal(t, 100, got.X)
|
||||
assert.Equal(t, 200, got.Y)
|
||||
}
|
||||
|
||||
func TestStateManager_UpdateMaximized_Good(t *testing.T) {
|
||||
sm := NewStateManagerWithDir(t.TempDir())
|
||||
sm.SetState("win", WindowState{Width: 800, Height: 600, Maximized: false})
|
||||
|
||||
sm.UpdateMaximized("win", true)
|
||||
|
||||
got, ok := sm.GetState("win")
|
||||
require.True(t, ok)
|
||||
assert.True(t, got.Maximized)
|
||||
|
||||
sm.UpdateMaximized("win", false)
|
||||
|
||||
got, ok = sm.GetState("win")
|
||||
require.True(t, ok)
|
||||
assert.False(t, got.Maximized)
|
||||
}
|
||||
|
||||
func TestStateManager_CaptureState_Good(t *testing.T) {
|
||||
sm := NewStateManagerWithDir(t.TempDir())
|
||||
pw := &mockWindow{
|
||||
name: "captured", x: 75, y: 125,
|
||||
width: 1440, height: 900, maximised: true,
|
||||
}
|
||||
|
||||
sm.CaptureState(pw)
|
||||
|
||||
got, ok := sm.GetState("captured")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, 75, got.X)
|
||||
assert.Equal(t, 125, got.Y)
|
||||
assert.Equal(t, 1440, got.Width)
|
||||
assert.Equal(t, 900, got.Height)
|
||||
assert.True(t, got.Maximized)
|
||||
assert.NotZero(t, got.UpdatedAt)
|
||||
}
|
||||
|
||||
func TestStateManager_ApplyState_Good(t *testing.T) {
|
||||
sm := NewStateManagerWithDir(t.TempDir())
|
||||
sm.SetState("target", WindowState{X: 55, Y: 65, Width: 700, Height: 500})
|
||||
|
||||
w := &Window{Name: "target", Width: 1280, Height: 800, X: 0, Y: 0}
|
||||
sm.ApplyState(w)
|
||||
|
||||
assert.Equal(t, 55, w.X)
|
||||
assert.Equal(t, 65, w.Y)
|
||||
assert.Equal(t, 700, w.Width)
|
||||
assert.Equal(t, 500, w.Height)
|
||||
}
|
||||
|
||||
func TestStateManager_ApplyState_NoState(t *testing.T) {
|
||||
sm := NewStateManagerWithDir(t.TempDir())
|
||||
|
||||
w := &Window{Name: "untouched", Width: 1280, Height: 800, X: 10, Y: 20}
|
||||
sm.ApplyState(w)
|
||||
|
||||
// Window should remain unchanged when no state is saved
|
||||
assert.Equal(t, 10, w.X)
|
||||
assert.Equal(t, 20, w.Y)
|
||||
assert.Equal(t, 1280, w.Width)
|
||||
assert.Equal(t, 800, w.Height)
|
||||
}
|
||||
|
||||
func TestStateManager_ListStates_Good(t *testing.T) {
|
||||
sm := NewStateManagerWithDir(t.TempDir())
|
||||
sm.SetState("alpha", WindowState{Width: 100})
|
||||
sm.SetState("beta", WindowState{Width: 200})
|
||||
sm.SetState("gamma", WindowState{Width: 300})
|
||||
|
||||
names := sm.ListStates()
|
||||
assert.Len(t, names, 3)
|
||||
assert.Contains(t, names, "alpha")
|
||||
assert.Contains(t, names, "beta")
|
||||
assert.Contains(t, names, "gamma")
|
||||
}
|
||||
|
||||
func TestStateManager_Clear_Good(t *testing.T) {
|
||||
sm := NewStateManagerWithDir(t.TempDir())
|
||||
sm.SetState("a", WindowState{Width: 100})
|
||||
sm.SetState("b", WindowState{Width: 200})
|
||||
sm.SetState("c", WindowState{Width: 300})
|
||||
|
||||
sm.Clear()
|
||||
|
||||
names := sm.ListStates()
|
||||
assert.Empty(t, names)
|
||||
|
||||
_, ok := sm.GetState("a")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestStateManager_Persistence_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// First manager: write state and force sync to disk
|
||||
sm1 := NewStateManagerWithDir(dir)
|
||||
sm1.SetState("persist-win", WindowState{
|
||||
X: 42, Y: 84, Width: 500, Height: 300,
|
||||
Maximized: true, Screen: "secondary", URL: "/settings",
|
||||
})
|
||||
sm1.ForceSync()
|
||||
|
||||
// Second manager: load from the same directory
|
||||
sm2 := NewStateManagerWithDir(dir)
|
||||
|
||||
got, ok := sm2.GetState("persist-win")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, 42, got.X)
|
||||
assert.Equal(t, 84, got.Y)
|
||||
assert.Equal(t, 500, got.Width)
|
||||
assert.Equal(t, 300, got.Height)
|
||||
assert.True(t, got.Maximized)
|
||||
assert.Equal(t, "secondary", got.Screen)
|
||||
assert.Equal(t, "/settings", got.URL)
|
||||
assert.NotZero(t, got.UpdatedAt)
|
||||
}
|
||||
|
||||
func TestStateManager_SetPath_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "custom", "window-state.json")
|
||||
|
||||
sm := NewStateManagerWithDir(dir)
|
||||
sm.SetPath(path)
|
||||
sm.SetState("custom", WindowState{Width: 640, Height: 480})
|
||||
sm.ForceSync()
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(content), "custom")
|
||||
}
|
||||
|
||||
// --- LayoutManager Persistence Tests ---
|
||||
|
||||
func TestLayoutManager_SaveAndGet_Good(t *testing.T) {
|
||||
lm := NewLayoutManagerWithDir(t.TempDir())
|
||||
windows := map[string]WindowState{
|
||||
"editor": {X: 0, Y: 0, Width: 960, Height: 1080},
|
||||
"terminal": {X: 960, Y: 0, Width: 960, Height: 540},
|
||||
"browser": {X: 960, Y: 540, Width: 960, Height: 540},
|
||||
}
|
||||
|
||||
err := lm.SaveLayout("coding", windows)
|
||||
require.NoError(t, err)
|
||||
|
||||
layout, ok := lm.GetLayout("coding")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "coding", layout.Name)
|
||||
assert.Len(t, layout.Windows, 3)
|
||||
assert.Equal(t, 960, layout.Windows["editor"].Width)
|
||||
assert.Equal(t, 1080, layout.Windows["editor"].Height)
|
||||
assert.Equal(t, 960, layout.Windows["terminal"].X)
|
||||
assert.NotZero(t, layout.CreatedAt)
|
||||
assert.NotZero(t, layout.UpdatedAt)
|
||||
assert.Equal(t, layout.CreatedAt, layout.UpdatedAt, "CreatedAt and UpdatedAt should match on first save")
|
||||
}
|
||||
|
||||
func TestLayoutManager_SaveLayout_EmptyName_Bad(t *testing.T) {
|
||||
lm := NewLayoutManagerWithDir(t.TempDir())
|
||||
err := lm.SaveLayout("", map[string]WindowState{
|
||||
"win": {Width: 800},
|
||||
})
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestLayoutManager_SaveLayout_Update_Good(t *testing.T) {
|
||||
lm := NewLayoutManagerWithDir(t.TempDir())
|
||||
|
||||
// First save
|
||||
err := lm.SaveLayout("evolving", map[string]WindowState{
|
||||
"win1": {Width: 800, Height: 600},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
first, ok := lm.GetLayout("evolving")
|
||||
require.True(t, ok)
|
||||
originalCreatedAt := first.CreatedAt
|
||||
originalUpdatedAt := first.UpdatedAt
|
||||
|
||||
// Small delay to ensure UpdatedAt differs
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
|
||||
// Second save with same name but different windows
|
||||
err = lm.SaveLayout("evolving", map[string]WindowState{
|
||||
"win1": {Width: 1024, Height: 768},
|
||||
"win2": {Width: 640, Height: 480},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
updated, ok := lm.GetLayout("evolving")
|
||||
require.True(t, ok)
|
||||
|
||||
// CreatedAt should be preserved from the original save
|
||||
assert.Equal(t, originalCreatedAt, updated.CreatedAt, "CreatedAt should be preserved on update")
|
||||
// UpdatedAt should be newer
|
||||
assert.GreaterOrEqual(t, updated.UpdatedAt, originalUpdatedAt, "UpdatedAt should advance on update")
|
||||
// Windows should reflect the second save
|
||||
assert.Len(t, updated.Windows, 2)
|
||||
assert.Equal(t, 1024, updated.Windows["win1"].Width)
|
||||
}
|
||||
|
||||
func TestLayoutManager_ListLayouts_Good(t *testing.T) {
|
||||
lm := NewLayoutManagerWithDir(t.TempDir())
|
||||
require.NoError(t, lm.SaveLayout("coding", map[string]WindowState{
|
||||
"editor": {Width: 960}, "terminal": {Width: 960},
|
||||
}))
|
||||
require.NoError(t, lm.SaveLayout("presenting", map[string]WindowState{
|
||||
"slides": {Width: 1920},
|
||||
}))
|
||||
require.NoError(t, lm.SaveLayout("debugging", map[string]WindowState{
|
||||
"code": {Width: 640}, "debugger": {Width: 640}, "console": {Width: 640},
|
||||
}))
|
||||
|
||||
infos := lm.ListLayouts()
|
||||
assert.Len(t, infos, 3)
|
||||
|
||||
// Build a lookup map for assertions regardless of order
|
||||
byName := make(map[string]LayoutInfo)
|
||||
for _, info := range infos {
|
||||
byName[info.Name] = info
|
||||
}
|
||||
|
||||
assert.Equal(t, 2, byName["coding"].WindowCount)
|
||||
assert.Equal(t, 1, byName["presenting"].WindowCount)
|
||||
assert.Equal(t, 3, byName["debugging"].WindowCount)
|
||||
}
|
||||
|
||||
func TestLayoutManager_DeleteLayout_Good(t *testing.T) {
|
||||
lm := NewLayoutManagerWithDir(t.TempDir())
|
||||
require.NoError(t, lm.SaveLayout("temporary", map[string]WindowState{
|
||||
"win": {Width: 800},
|
||||
}))
|
||||
|
||||
// Verify it exists
|
||||
_, ok := lm.GetLayout("temporary")
|
||||
require.True(t, ok)
|
||||
|
||||
lm.DeleteLayout("temporary")
|
||||
|
||||
// Verify it is gone
|
||||
_, ok = lm.GetLayout("temporary")
|
||||
assert.False(t, ok)
|
||||
|
||||
// Verify list is empty
|
||||
assert.Empty(t, lm.ListLayouts())
|
||||
}
|
||||
|
||||
func TestLayoutManager_Persistence_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
// First manager: save layout to disk
|
||||
lm1 := NewLayoutManagerWithDir(dir)
|
||||
err := lm1.SaveLayout("persisted", map[string]WindowState{
|
||||
"main": {X: 0, Y: 0, Width: 1280, Height: 800},
|
||||
"sidebar": {X: 1280, Y: 0, Width: 640, Height: 800},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Second manager: load from the same directory
|
||||
lm2 := NewLayoutManagerWithDir(dir)
|
||||
|
||||
layout, ok := lm2.GetLayout("persisted")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "persisted", layout.Name)
|
||||
assert.Len(t, layout.Windows, 2)
|
||||
assert.Equal(t, 1280, layout.Windows["main"].Width)
|
||||
assert.Equal(t, 800, layout.Windows["main"].Height)
|
||||
assert.Equal(t, 640, layout.Windows["sidebar"].Width)
|
||||
assert.NotZero(t, layout.CreatedAt)
|
||||
assert.NotZero(t, layout.UpdatedAt)
|
||||
}
|
||||
|
|
@ -3,25 +3,25 @@ package window
|
|||
|
||||
// Platform abstracts the windowing backend (Wails v3).
|
||||
type Platform interface {
|
||||
CreateWindow(opts PlatformWindowOptions) PlatformWindow
|
||||
CreateWindow(options PlatformWindowOptions) PlatformWindow
|
||||
GetWindows() []PlatformWindow
|
||||
}
|
||||
|
||||
// PlatformWindowOptions are the backend-specific options passed to CreateWindow.
|
||||
type PlatformWindowOptions struct {
|
||||
Name string
|
||||
Title string
|
||||
URL string
|
||||
Width, Height int
|
||||
X, Y int
|
||||
MinWidth, MinHeight int
|
||||
MaxWidth, MaxHeight int
|
||||
Frameless bool
|
||||
Hidden bool
|
||||
AlwaysOnTop bool
|
||||
BackgroundColour [4]uint8 // RGBA
|
||||
DisableResize bool
|
||||
EnableFileDrop bool
|
||||
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 // RGBA
|
||||
DisableResize bool
|
||||
EnableFileDrop bool
|
||||
}
|
||||
|
||||
// PlatformWindow is a live window handle from the backend.
|
||||
|
|
|
|||
|
|
@ -2,8 +2,6 @@ package window
|
|||
|
||||
import "forge.lthn.ai/core/go/pkg/core"
|
||||
|
||||
// Register creates a factory closure that captures the Platform adapter.
|
||||
// The returned function has the signature WithService requires: func(*Core) (any, error).
|
||||
func Register(p Platform) func(*core.Core) (any, error) {
|
||||
return func(c *core.Core) (any, error) {
|
||||
return &Service{
|
||||
|
|
|
|||
|
|
@ -7,61 +7,51 @@ import (
|
|||
"forge.lthn.ai/core/go/pkg/core"
|
||||
)
|
||||
|
||||
// Options holds configuration for the window service.
|
||||
type Options struct{}
|
||||
|
||||
// Service is a core.Service managing window lifecycle via IPC.
|
||||
// It embeds ServiceRuntime for Core access and composes Manager for platform operations.
|
||||
type Service struct {
|
||||
*core.ServiceRuntime[Options]
|
||||
manager *Manager
|
||||
platform Platform
|
||||
}
|
||||
|
||||
// OnStartup queries config from the display orchestrator and registers IPC handlers.
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
// Query config — display registers its handler before us (registration order guarantee).
|
||||
// If display is not registered, handled=false and we skip config.
|
||||
cfg, handled, _ := s.Core().QUERY(QueryConfig{})
|
||||
configValue, handled, _ := s.Core().QUERY(QueryConfig{})
|
||||
if handled {
|
||||
if wCfg, ok := cfg.(map[string]any); ok {
|
||||
s.applyConfig(wCfg)
|
||||
if windowConfig, ok := configValue.(map[string]any); ok {
|
||||
s.applyConfig(windowConfig)
|
||||
}
|
||||
}
|
||||
|
||||
// Register QUERY and TASK handlers manually.
|
||||
// ACTION handler (HandleIPCEvents) is auto-registered by WithService —
|
||||
// do NOT call RegisterAction here or actions will double-fire.
|
||||
s.Core().RegisterQuery(s.handleQuery)
|
||||
s.Core().RegisterTask(s.handleTask)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) applyConfig(cfg map[string]any) {
|
||||
if w, ok := cfg["default_width"]; ok {
|
||||
if _, ok := w.(int); ok {
|
||||
// TODO: s.manager.SetDefaultWidth(width) — add when Manager API is extended
|
||||
func (s *Service) applyConfig(configData map[string]any) {
|
||||
if width, ok := configData["default_width"]; ok {
|
||||
if width, ok := width.(int); ok {
|
||||
s.manager.SetDefaultWidth(width)
|
||||
}
|
||||
}
|
||||
if h, ok := cfg["default_height"]; ok {
|
||||
if _, ok := h.(int); ok {
|
||||
// TODO: s.manager.SetDefaultHeight(height) — add when Manager API is extended
|
||||
if height, ok := configData["default_height"]; ok {
|
||||
if height, ok := height.(int); ok {
|
||||
s.manager.SetDefaultHeight(height)
|
||||
}
|
||||
}
|
||||
if sf, ok := cfg["state_file"]; ok {
|
||||
if _, ok := sf.(string); ok {
|
||||
// TODO: s.manager.State().SetPath(stateFile) — add when StateManager API is extended
|
||||
if stateFile, ok := configData["state_file"]; ok {
|
||||
if stateFile, ok := stateFile.(string); ok {
|
||||
s.manager.State().SetPath(stateFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HandleIPCEvents is auto-discovered and registered by core.WithService.
|
||||
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- Query Handlers ---
|
||||
|
||||
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
|
||||
switch q := q.(type) {
|
||||
case QueryWindowList:
|
||||
|
|
@ -123,7 +113,7 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
|||
case TaskSetPosition:
|
||||
return nil, true, s.taskSetPosition(t.Name, t.X, t.Y)
|
||||
case TaskSetSize:
|
||||
return nil, true, s.taskSetSize(t.Name, t.W, t.H)
|
||||
return nil, true, s.taskSetSize(t.Name, t.Width, t.Height)
|
||||
case TaskMaximise:
|
||||
return nil, true, s.taskMaximise(t.Name)
|
||||
case TaskMinimise:
|
||||
|
|
@ -155,7 +145,7 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
|
|||
}
|
||||
|
||||
func (s *Service) taskOpenWindow(t TaskOpenWindow) (any, bool, error) {
|
||||
pw, err := s.manager.Open(t.Opts...)
|
||||
pw, err := s.manager.Open(t.Options...)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
|
|
@ -189,7 +179,7 @@ func (s *Service) trackWindow(pw PlatformWindow) {
|
|||
if data := e.Data; data != nil {
|
||||
w, _ := data["w"].(int)
|
||||
h, _ := data["h"].(int)
|
||||
_ = s.Core().ACTION(ActionWindowResized{Name: e.Name, W: w, H: h})
|
||||
_ = s.Core().ACTION(ActionWindowResized{Name: e.Name, Width: w, Height: h})
|
||||
}
|
||||
case "close":
|
||||
_ = s.Core().ACTION(ActionWindowClosed{Name: e.Name})
|
||||
|
|
@ -227,13 +217,13 @@ func (s *Service) taskSetPosition(name string, x, y int) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) taskSetSize(name string, w, h int) error {
|
||||
func (s *Service) taskSetSize(name string, width, height int) error {
|
||||
pw, ok := s.manager.Get(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("window not found: %s", name)
|
||||
}
|
||||
pw.SetSize(w, h)
|
||||
s.manager.State().UpdateSize(name, w, h)
|
||||
pw.SetSize(width, height)
|
||||
s.manager.State().UpdateSize(name, width, height)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ func TestRegister_Good(t *testing.T) {
|
|||
func TestTaskOpenWindow_Good(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
result, handled, err := c.PERFORM(TaskOpenWindow{
|
||||
Opts: []WindowOption{WithName("test"), WithURL("/")},
|
||||
Options: []WindowOption{WithName("test"), WithURL("/")},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
|
@ -49,8 +49,8 @@ func TestTaskOpenWindow_Bad(t *testing.T) {
|
|||
|
||||
func TestQueryWindowList_Good(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("a")}})
|
||||
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("b")}})
|
||||
_, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("a")}})
|
||||
_, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("b")}})
|
||||
|
||||
result, handled, err := c.QUERY(QueryWindowList{})
|
||||
require.NoError(t, err)
|
||||
|
|
@ -61,7 +61,7 @@ func TestQueryWindowList_Good(t *testing.T) {
|
|||
|
||||
func TestQueryWindowByName_Good(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
|
||||
_, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||
|
||||
result, handled, err := c.QUERY(QueryWindowByName{Name: "test"})
|
||||
require.NoError(t, err)
|
||||
|
|
@ -80,7 +80,7 @@ func TestQueryWindowByName_Bad(t *testing.T) {
|
|||
|
||||
func TestTaskCloseWindow_Good(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
|
||||
_, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskCloseWindow{Name: "test"})
|
||||
require.NoError(t, err)
|
||||
|
|
@ -100,7 +100,7 @@ func TestTaskCloseWindow_Bad(t *testing.T) {
|
|||
|
||||
func TestTaskSetPosition_Good(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
|
||||
_, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskSetPosition{Name: "test", X: 100, Y: 200})
|
||||
require.NoError(t, err)
|
||||
|
|
@ -114,9 +114,9 @@ func TestTaskSetPosition_Good(t *testing.T) {
|
|||
|
||||
func TestTaskSetSize_Good(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
|
||||
_, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskSetSize{Name: "test", W: 800, H: 600})
|
||||
_, handled, err := c.PERFORM(TaskSetSize{Name: "test", Width: 800, Height: 600})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
|
|
@ -128,7 +128,7 @@ func TestTaskSetSize_Good(t *testing.T) {
|
|||
|
||||
func TestTaskMaximise_Good(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}})
|
||||
_, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskMaximise{Name: "test"})
|
||||
require.NoError(t, err)
|
||||
|
|
@ -144,7 +144,7 @@ func TestFileDrop_Good(t *testing.T) {
|
|||
|
||||
// Open a window
|
||||
result, _, _ := c.PERFORM(TaskOpenWindow{
|
||||
Opts: []WindowOption{WithName("drop-test")},
|
||||
Options: []WindowOption{WithName("drop-test")},
|
||||
})
|
||||
info := result.(WindowInfo)
|
||||
assert.Equal(t, "drop-test", info.Name)
|
||||
|
|
@ -174,3 +174,238 @@ func TestFileDrop_Good(t *testing.T) {
|
|||
assert.Equal(t, "upload-zone", dropped.TargetID)
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
// --- TaskMinimise ---
|
||||
|
||||
func TestTaskMinimise_Good(t *testing.T) {
|
||||
svc, c := newTestWindowService(t)
|
||||
_, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskMinimise{Name: "test"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
pw, ok := svc.Manager().Get("test")
|
||||
require.True(t, ok)
|
||||
mw := pw.(*mockWindow)
|
||||
assert.True(t, mw.minimised)
|
||||
}
|
||||
|
||||
func TestTaskMinimise_Bad(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, handled, err := c.PERFORM(TaskMinimise{Name: "nonexistent"})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- TaskFocus ---
|
||||
|
||||
func TestTaskFocus_Good(t *testing.T) {
|
||||
svc, c := newTestWindowService(t)
|
||||
_, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskFocus{Name: "test"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
pw, ok := svc.Manager().Get("test")
|
||||
require.True(t, ok)
|
||||
mw := pw.(*mockWindow)
|
||||
assert.True(t, mw.focused)
|
||||
}
|
||||
|
||||
func TestTaskFocus_Bad(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, handled, err := c.PERFORM(TaskFocus{Name: "nonexistent"})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- TaskRestore ---
|
||||
|
||||
func TestTaskRestore_Good(t *testing.T) {
|
||||
svc, c := newTestWindowService(t)
|
||||
_, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||
|
||||
// First maximise, then restore
|
||||
_, _, _ = c.PERFORM(TaskMaximise{Name: "test"})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskRestore{Name: "test"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
pw, ok := svc.Manager().Get("test")
|
||||
require.True(t, ok)
|
||||
mw := pw.(*mockWindow)
|
||||
assert.False(t, mw.maximised)
|
||||
|
||||
// Verify state was updated
|
||||
state, ok := svc.Manager().State().GetState("test")
|
||||
assert.True(t, ok)
|
||||
assert.False(t, state.Maximized)
|
||||
}
|
||||
|
||||
func TestTaskRestore_Bad(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, handled, err := c.PERFORM(TaskRestore{Name: "nonexistent"})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- TaskSetTitle ---
|
||||
|
||||
func TestTaskSetTitle_Good(t *testing.T) {
|
||||
svc, c := newTestWindowService(t)
|
||||
_, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskSetTitle{Name: "test", Title: "New Title"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
pw, ok := svc.Manager().Get("test")
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "New Title", pw.Title())
|
||||
}
|
||||
|
||||
func TestTaskSetTitle_Bad(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, handled, err := c.PERFORM(TaskSetTitle{Name: "nonexistent", Title: "Nope"})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- TaskSetVisibility ---
|
||||
|
||||
func TestTaskSetVisibility_Good(t *testing.T) {
|
||||
svc, c := newTestWindowService(t)
|
||||
_, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskSetVisibility{Name: "test", Visible: true})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
pw, ok := svc.Manager().Get("test")
|
||||
require.True(t, ok)
|
||||
mw := pw.(*mockWindow)
|
||||
assert.True(t, mw.visible)
|
||||
|
||||
// Now hide it
|
||||
_, handled, err = c.PERFORM(TaskSetVisibility{Name: "test", Visible: false})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.False(t, mw.visible)
|
||||
}
|
||||
|
||||
func TestTaskSetVisibility_Bad(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, handled, err := c.PERFORM(TaskSetVisibility{Name: "nonexistent", Visible: true})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- TaskFullscreen ---
|
||||
|
||||
func TestTaskFullscreen_Good(t *testing.T) {
|
||||
svc, c := newTestWindowService(t)
|
||||
_, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}})
|
||||
|
||||
// Enter fullscreen
|
||||
_, handled, err := c.PERFORM(TaskFullscreen{Name: "test", Fullscreen: true})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
pw, ok := svc.Manager().Get("test")
|
||||
require.True(t, ok)
|
||||
mw := pw.(*mockWindow)
|
||||
assert.True(t, mw.fullscreened)
|
||||
|
||||
// Exit fullscreen
|
||||
_, handled, err = c.PERFORM(TaskFullscreen{Name: "test", Fullscreen: false})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
assert.False(t, mw.fullscreened)
|
||||
}
|
||||
|
||||
func TestTaskFullscreen_Bad(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, handled, err := c.PERFORM(TaskFullscreen{Name: "nonexistent", Fullscreen: true})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- TaskSaveLayout ---
|
||||
|
||||
func TestTaskSaveLayout_Good(t *testing.T) {
|
||||
svc, c := newTestWindowService(t)
|
||||
_, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("editor"), WithSize(960, 1080), WithPosition(0, 0)}})
|
||||
_, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("terminal"), WithSize(960, 1080), WithPosition(960, 0)}})
|
||||
|
||||
_, handled, err := c.PERFORM(TaskSaveLayout{Name: "coding"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
// Verify layout was saved with correct window states
|
||||
layout, ok := svc.Manager().Layout().GetLayout("coding")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "coding", layout.Name)
|
||||
assert.Len(t, layout.Windows, 2)
|
||||
|
||||
editorState, ok := layout.Windows["editor"]
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, 0, editorState.X)
|
||||
assert.Equal(t, 960, editorState.Width)
|
||||
|
||||
termState, ok := layout.Windows["terminal"]
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, 960, termState.X)
|
||||
assert.Equal(t, 960, termState.Width)
|
||||
}
|
||||
|
||||
func TestTaskSaveLayout_Bad(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
// Saving an empty layout with empty name returns an error from LayoutManager
|
||||
_, handled, err := c.PERFORM(TaskSaveLayout{Name: ""})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
// --- TaskRestoreLayout ---
|
||||
|
||||
func TestTaskRestoreLayout_Good(t *testing.T) {
|
||||
svc, c := newTestWindowService(t)
|
||||
// Open windows
|
||||
_, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("editor"), WithSize(800, 600), WithPosition(0, 0)}})
|
||||
_, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("terminal"), WithSize(800, 600), WithPosition(0, 0)}})
|
||||
|
||||
// Save a layout with specific positions
|
||||
_, _, _ = c.PERFORM(TaskSaveLayout{Name: "coding"})
|
||||
|
||||
// Move the windows to different positions
|
||||
_, _, _ = c.PERFORM(TaskSetPosition{Name: "editor", X: 500, Y: 500})
|
||||
_, _, _ = c.PERFORM(TaskSetPosition{Name: "terminal", X: 600, Y: 600})
|
||||
|
||||
// Restore the layout
|
||||
_, handled, err := c.PERFORM(TaskRestoreLayout{Name: "coding"})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
// Verify windows were moved back to saved positions
|
||||
pw, ok := svc.Manager().Get("editor")
|
||||
require.True(t, ok)
|
||||
x, y := pw.Position()
|
||||
assert.Equal(t, 0, x)
|
||||
assert.Equal(t, 0, y)
|
||||
|
||||
pw2, ok := svc.Manager().Get("terminal")
|
||||
require.True(t, ok)
|
||||
x2, y2 := pw2.Position()
|
||||
assert.Equal(t, 0, x2)
|
||||
assert.Equal(t, 0, y2)
|
||||
}
|
||||
|
||||
func TestTaskRestoreLayout_Bad(t *testing.T) {
|
||||
_, c := newTestWindowService(t)
|
||||
_, handled, err := c.PERFORM(TaskRestoreLayout{Name: "nonexistent"})
|
||||
assert.True(t, handled)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import (
|
|||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
coreio "forge.lthn.ai/core/go-io"
|
||||
)
|
||||
|
||||
// WindowState holds the persisted position/size of a window.
|
||||
|
|
@ -25,6 +27,7 @@ type WindowState struct {
|
|||
// StateManager persists window positions to ~/.config/Core/window_state.json.
|
||||
type StateManager struct {
|
||||
configDir string
|
||||
statePath string
|
||||
states map[string]WindowState
|
||||
mu sync.RWMutex
|
||||
saveTimer *time.Timer
|
||||
|
|
@ -55,24 +58,49 @@ func NewStateManagerWithDir(configDir string) *StateManager {
|
|||
}
|
||||
|
||||
func (sm *StateManager) filePath() string {
|
||||
if sm.statePath != "" {
|
||||
return sm.statePath
|
||||
}
|
||||
return filepath.Join(sm.configDir, "window_state.json")
|
||||
}
|
||||
|
||||
func (sm *StateManager) load() {
|
||||
if sm.configDir == "" {
|
||||
func (sm *StateManager) dataDir() string {
|
||||
if sm.statePath != "" {
|
||||
return filepath.Dir(sm.statePath)
|
||||
}
|
||||
return sm.configDir
|
||||
}
|
||||
|
||||
func (sm *StateManager) SetPath(path string) {
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
data, err := os.ReadFile(sm.filePath())
|
||||
sm.mu.Lock()
|
||||
if sm.saveTimer != nil {
|
||||
sm.saveTimer.Stop()
|
||||
sm.saveTimer = nil
|
||||
}
|
||||
sm.statePath = path
|
||||
sm.states = make(map[string]WindowState)
|
||||
sm.mu.Unlock()
|
||||
sm.load()
|
||||
}
|
||||
|
||||
func (sm *StateManager) load() {
|
||||
if sm.configDir == "" && sm.statePath == "" {
|
||||
return
|
||||
}
|
||||
content, err := coreio.Local.Read(sm.filePath())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
_ = json.Unmarshal(data, &sm.states)
|
||||
_ = json.Unmarshal([]byte(content), &sm.states)
|
||||
}
|
||||
|
||||
func (sm *StateManager) save() {
|
||||
if sm.configDir == "" {
|
||||
if sm.configDir == "" && sm.statePath == "" {
|
||||
return
|
||||
}
|
||||
sm.mu.RLock()
|
||||
|
|
@ -81,8 +109,10 @@ func (sm *StateManager) save() {
|
|||
if err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.MkdirAll(sm.configDir, 0o755)
|
||||
_ = os.WriteFile(sm.filePath(), data, 0o644)
|
||||
if dir := sm.dataDir(); dir != "" {
|
||||
_ = coreio.Local.EnsureDir(dir)
|
||||
}
|
||||
_ = coreio.Local.Write(sm.filePath(), string(data))
|
||||
}
|
||||
|
||||
func (sm *StateManager) scheduleSave() {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// pkg/window/tiling.go
|
||||
package window
|
||||
|
||||
import "fmt"
|
||||
import coreerr "forge.lthn.ai/core/go-log"
|
||||
|
||||
// TileMode defines how windows are arranged.
|
||||
type TileMode int
|
||||
|
|
@ -67,12 +67,12 @@ func (m *Manager) TileWindows(mode TileMode, names []string, screenW, screenH in
|
|||
for _, name := range names {
|
||||
pw, ok := m.Get(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("window %q not found", name)
|
||||
return coreerr.E("window.Manager.TileWindows", "window not found: "+name, nil)
|
||||
}
|
||||
windows = append(windows, pw)
|
||||
}
|
||||
if len(windows) == 0 {
|
||||
return fmt.Errorf("no windows to tile")
|
||||
return coreerr.E("window.Manager.TileWindows", "no windows to tile", nil)
|
||||
}
|
||||
|
||||
halfW, halfH := screenW/2, screenH/2
|
||||
|
|
@ -146,7 +146,7 @@ func (m *Manager) TileWindows(mode TileMode, names []string, screenW, screenH in
|
|||
func (m *Manager) SnapWindow(name string, pos SnapPosition, screenW, screenH int) error {
|
||||
pw, ok := m.Get(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("window %q not found", name)
|
||||
return coreerr.E("window.Manager.SnapWindow", "window not found: "+name, nil)
|
||||
}
|
||||
|
||||
halfW, halfH := screenW/2, screenH/2
|
||||
|
|
@ -188,7 +188,7 @@ func (m *Manager) StackWindows(names []string, offsetX, offsetY int) error {
|
|||
for i, name := range names {
|
||||
pw, ok := m.Get(name)
|
||||
if !ok {
|
||||
return fmt.Errorf("window %q not found", name)
|
||||
return coreerr.E("window.Manager.StackWindows", "window not found: "+name, nil)
|
||||
}
|
||||
pw.SetPosition(i*offsetX, i*offsetY)
|
||||
}
|
||||
|
|
@ -198,7 +198,7 @@ func (m *Manager) StackWindows(names []string, offsetX, offsetY int) error {
|
|||
// ApplyWorkflow arranges windows in a predefined workflow layout.
|
||||
func (m *Manager) ApplyWorkflow(workflow WorkflowLayout, names []string, screenW, screenH int) error {
|
||||
if len(names) == 0 {
|
||||
return fmt.Errorf("no windows for workflow")
|
||||
return coreerr.E("window.Manager.ApplyWorkflow", "no windows for workflow", nil)
|
||||
}
|
||||
|
||||
switch workflow {
|
||||
|
|
|
|||
|
|
@ -16,28 +16,28 @@ func NewWailsPlatform(app *application.App) *WailsPlatform {
|
|||
return &WailsPlatform{app: app}
|
||||
}
|
||||
|
||||
func (wp *WailsPlatform) CreateWindow(opts PlatformWindowOptions) PlatformWindow {
|
||||
func (wp *WailsPlatform) CreateWindow(options 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: options.Name,
|
||||
Title: options.Title,
|
||||
URL: options.URL,
|
||||
Width: options.Width,
|
||||
Height: options.Height,
|
||||
X: options.X,
|
||||
Y: options.Y,
|
||||
MinWidth: options.MinWidth,
|
||||
MinHeight: options.MinHeight,
|
||||
MaxWidth: options.MaxWidth,
|
||||
MaxHeight: options.MaxHeight,
|
||||
Frameless: options.Frameless,
|
||||
Hidden: options.Hidden,
|
||||
AlwaysOnTop: options.AlwaysOnTop,
|
||||
DisableResize: options.DisableResize,
|
||||
EnableFileDrop: options.EnableFileDrop,
|
||||
BackgroundColour: application.NewRGBA(options.BackgroundColour[0], options.BackgroundColour[1], options.BackgroundColour[2], options.BackgroundColour[3]),
|
||||
}
|
||||
w := wp.app.Window.NewWithOptions(wOpts)
|
||||
return &wailsWindow{w: w, title: opts.Title}
|
||||
return &wailsWindow{w: w, title: options.Title}
|
||||
}
|
||||
|
||||
func (wp *WailsPlatform) GetWindows() []PlatformWindow {
|
||||
|
|
@ -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))
|
||||
|
|
@ -140,4 +140,3 @@ var _ PlatformWindow = (*wailsWindow)(nil)
|
|||
|
||||
// Ensure WailsPlatform satisfies Platform at compile time.
|
||||
var _ Platform = (*WailsPlatform)(nil)
|
||||
|
||||
|
|
|
|||
|
|
@ -8,19 +8,19 @@ import (
|
|||
|
||||
// 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.
|
||||
|
|
@ -38,11 +38,13 @@ func (w *Window) ToPlatformOptions() PlatformWindowOptions {
|
|||
|
||||
// Manager manages window lifecycle through a Platform backend.
|
||||
type Manager struct {
|
||||
platform Platform
|
||||
state *StateManager
|
||||
layout *LayoutManager
|
||||
windows map[string]PlatformWindow
|
||||
mu sync.RWMutex
|
||||
platform Platform
|
||||
state *StateManager
|
||||
layout *LayoutManager
|
||||
windows map[string]PlatformWindow
|
||||
defaultWidth int
|
||||
defaultHeight int
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// NewManager creates a window Manager with the given platform backend.
|
||||
|
|
@ -66,9 +68,21 @@ func NewManagerWithDir(platform Platform, configDir string) *Manager {
|
|||
}
|
||||
}
|
||||
|
||||
func (m *Manager) SetDefaultWidth(width int) {
|
||||
if width > 0 {
|
||||
m.defaultWidth = width
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) SetDefaultHeight(height int) {
|
||||
if height > 0 {
|
||||
m.defaultHeight = height
|
||||
}
|
||||
}
|
||||
|
||||
// Open creates a window using functional options, applies saved state, and tracks it.
|
||||
func (m *Manager) Open(opts ...WindowOption) (PlatformWindow, error) {
|
||||
w, err := ApplyOptions(opts...)
|
||||
func (m *Manager) Open(options ...WindowOption) (PlatformWindow, error) {
|
||||
w, err := ApplyOptions(options...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("window.Manager.Open: %w", err)
|
||||
}
|
||||
|
|
@ -84,10 +98,18 @@ func (m *Manager) Create(w *Window) (PlatformWindow, error) {
|
|||
w.Title = "Core"
|
||||
}
|
||||
if w.Width == 0 {
|
||||
w.Width = 1280
|
||||
if m.defaultWidth > 0 {
|
||||
w.Width = m.defaultWidth
|
||||
} else {
|
||||
w.Width = 1280
|
||||
}
|
||||
}
|
||||
if w.Height == 0 {
|
||||
w.Height = 800
|
||||
if m.defaultHeight > 0 {
|
||||
w.Height = m.defaultHeight
|
||||
} else {
|
||||
w.Height = 800
|
||||
}
|
||||
}
|
||||
if w.URL == "" {
|
||||
w.URL = "/"
|
||||
|
|
|
|||
|
|
@ -110,6 +110,19 @@ func TestManager_Open_Defaults_Good(t *testing.T) {
|
|||
assert.Equal(t, 800, h)
|
||||
}
|
||||
|
||||
func TestManager_Open_CustomDefaults_Good(t *testing.T) {
|
||||
m, _ := newTestManager()
|
||||
m.SetDefaultWidth(1440)
|
||||
m.SetDefaultHeight(900)
|
||||
|
||||
pw, err := m.Open()
|
||||
require.NoError(t, err)
|
||||
|
||||
w, h := pw.Size()
|
||||
assert.Equal(t, 1440, w)
|
||||
assert.Equal(t, 900, h)
|
||||
}
|
||||
|
||||
func TestManager_Open_Bad(t *testing.T) {
|
||||
m, _ := newTestManager()
|
||||
_, err := m.Open(func(w *Window) error { return assert.AnError })
|
||||
|
|
@ -148,131 +161,6 @@ func TestManager_Remove_Good(t *testing.T) {
|
|||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
// --- StateManager Tests ---
|
||||
|
||||
// newTestStateManager creates a clean StateManager with a temp dir for testing.
|
||||
func newTestStateManager(t *testing.T) *StateManager {
|
||||
return &StateManager{
|
||||
configDir: t.TempDir(),
|
||||
states: make(map[string]WindowState),
|
||||
}
|
||||
}
|
||||
|
||||
func TestStateManager_SetGet_Good(t *testing.T) {
|
||||
sm := newTestStateManager(t)
|
||||
state := WindowState{X: 100, Y: 200, Width: 800, Height: 600, Maximized: false}
|
||||
sm.SetState("main", state)
|
||||
got, ok := sm.GetState("main")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, 100, got.X)
|
||||
assert.Equal(t, 800, got.Width)
|
||||
}
|
||||
|
||||
func TestStateManager_SetGet_Bad(t *testing.T) {
|
||||
sm := newTestStateManager(t)
|
||||
_, ok := sm.GetState("nonexistent")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestStateManager_CaptureState_Good(t *testing.T) {
|
||||
sm := newTestStateManager(t)
|
||||
w := &mockWindow{name: "cap", x: 50, y: 60, width: 1024, height: 768, maximised: true}
|
||||
sm.CaptureState(w)
|
||||
got, ok := sm.GetState("cap")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, 50, got.X)
|
||||
assert.Equal(t, 1024, got.Width)
|
||||
assert.True(t, got.Maximized)
|
||||
}
|
||||
|
||||
func TestStateManager_ApplyState_Good(t *testing.T) {
|
||||
sm := newTestStateManager(t)
|
||||
sm.SetState("win", WindowState{X: 10, Y: 20, Width: 640, Height: 480})
|
||||
w := &Window{Name: "win", Width: 1280, Height: 800}
|
||||
sm.ApplyState(w)
|
||||
assert.Equal(t, 10, w.X)
|
||||
assert.Equal(t, 20, w.Y)
|
||||
assert.Equal(t, 640, w.Width)
|
||||
assert.Equal(t, 480, w.Height)
|
||||
}
|
||||
|
||||
func TestStateManager_ListStates_Good(t *testing.T) {
|
||||
sm := newTestStateManager(t)
|
||||
sm.SetState("a", WindowState{Width: 100})
|
||||
sm.SetState("b", WindowState{Width: 200})
|
||||
names := sm.ListStates()
|
||||
assert.Len(t, names, 2)
|
||||
}
|
||||
|
||||
func TestStateManager_Clear_Good(t *testing.T) {
|
||||
sm := newTestStateManager(t)
|
||||
sm.SetState("a", WindowState{Width: 100})
|
||||
sm.Clear()
|
||||
names := sm.ListStates()
|
||||
assert.Empty(t, names)
|
||||
}
|
||||
|
||||
func TestStateManager_Persistence_Good(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
sm1 := &StateManager{configDir: dir, states: make(map[string]WindowState)}
|
||||
sm1.SetState("persist", WindowState{X: 42, Y: 84, Width: 500, Height: 300})
|
||||
sm1.ForceSync()
|
||||
|
||||
sm2 := &StateManager{configDir: dir, states: make(map[string]WindowState)}
|
||||
sm2.load()
|
||||
got, ok := sm2.GetState("persist")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, 42, got.X)
|
||||
assert.Equal(t, 500, got.Width)
|
||||
}
|
||||
|
||||
// --- LayoutManager Tests ---
|
||||
|
||||
// newTestLayoutManager creates a clean LayoutManager with a temp dir for testing.
|
||||
func newTestLayoutManager(t *testing.T) *LayoutManager {
|
||||
return &LayoutManager{
|
||||
configDir: t.TempDir(),
|
||||
layouts: make(map[string]Layout),
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayoutManager_SaveGet_Good(t *testing.T) {
|
||||
lm := newTestLayoutManager(t)
|
||||
states := map[string]WindowState{
|
||||
"editor": {X: 0, Y: 0, Width: 960, Height: 1080},
|
||||
"terminal": {X: 960, Y: 0, Width: 960, Height: 1080},
|
||||
}
|
||||
err := lm.SaveLayout("coding", states)
|
||||
require.NoError(t, err)
|
||||
|
||||
layout, ok := lm.GetLayout("coding")
|
||||
assert.True(t, ok)
|
||||
assert.Equal(t, "coding", layout.Name)
|
||||
assert.Len(t, layout.Windows, 2)
|
||||
}
|
||||
|
||||
func TestLayoutManager_GetLayout_Bad(t *testing.T) {
|
||||
lm := newTestLayoutManager(t)
|
||||
_, ok := lm.GetLayout("nonexistent")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
func TestLayoutManager_ListLayouts_Good(t *testing.T) {
|
||||
lm := newTestLayoutManager(t)
|
||||
_ = lm.SaveLayout("a", map[string]WindowState{})
|
||||
_ = lm.SaveLayout("b", map[string]WindowState{})
|
||||
layouts := lm.ListLayouts()
|
||||
assert.Len(t, layouts, 2)
|
||||
}
|
||||
|
||||
func TestLayoutManager_DeleteLayout_Good(t *testing.T) {
|
||||
lm := newTestLayoutManager(t)
|
||||
_ = lm.SaveLayout("temp", map[string]WindowState{})
|
||||
lm.DeleteLayout("temp")
|
||||
_, ok := lm.GetLayout("temp")
|
||||
assert.False(t, ok)
|
||||
}
|
||||
|
||||
// --- Tiling Tests ---
|
||||
|
||||
func TestTileMode_String_Good(t *testing.T) {
|
||||
|
|
@ -328,3 +216,190 @@ func TestWorkflowLayout_Good(t *testing.T) {
|
|||
assert.Equal(t, "coding", WorkflowCoding.String())
|
||||
assert.Equal(t, "debugging", WorkflowDebugging.String())
|
||||
}
|
||||
|
||||
// --- Comprehensive Tiling Tests ---
|
||||
|
||||
func TestTileWindows_AllModes_Good(t *testing.T) {
|
||||
const screenW, screenH = 1920, 1080
|
||||
halfW, halfH := screenW/2, screenH/2
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
mode TileMode
|
||||
wantX int
|
||||
wantY int
|
||||
wantWidth int
|
||||
wantHeight int
|
||||
}{
|
||||
{"LeftHalf", TileModeLeftHalf, 0, 0, halfW, screenH},
|
||||
{"RightHalf", TileModeRightHalf, halfW, 0, halfW, screenH},
|
||||
{"TopHalf", TileModeTopHalf, 0, 0, screenW, halfH},
|
||||
{"BottomHalf", TileModeBottomHalf, 0, halfH, screenW, halfH},
|
||||
{"TopLeft", TileModeTopLeft, 0, 0, halfW, halfH},
|
||||
{"TopRight", TileModeTopRight, halfW, 0, halfW, halfH},
|
||||
{"BottomLeft", TileModeBottomLeft, 0, halfH, halfW, halfH},
|
||||
{"BottomRight", TileModeBottomRight, halfW, halfH, halfW, halfH},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
m, _ := newTestManager()
|
||||
_, err := m.Open(WithName("win"), WithSize(800, 600))
|
||||
require.NoError(t, err)
|
||||
|
||||
err = m.TileWindows(tc.mode, []string{"win"}, screenW, screenH)
|
||||
require.NoError(t, err)
|
||||
|
||||
pw, ok := m.Get("win")
|
||||
require.True(t, ok)
|
||||
|
||||
x, y := pw.Position()
|
||||
w, h := pw.Size()
|
||||
assert.Equal(t, tc.wantX, x, "x position")
|
||||
assert.Equal(t, tc.wantY, y, "y position")
|
||||
assert.Equal(t, tc.wantWidth, w, "width")
|
||||
assert.Equal(t, tc.wantHeight, h, "height")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapWindow_AllPositions_Good(t *testing.T) {
|
||||
const screenW, screenH = 1920, 1080
|
||||
halfW, halfH := screenW/2, screenH/2
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pos SnapPosition
|
||||
initW int
|
||||
initH int
|
||||
wantX int
|
||||
wantY int
|
||||
wantWidth int
|
||||
wantHeight int
|
||||
}{
|
||||
{"Right", SnapRight, 800, 600, halfW, 0, halfW, screenH},
|
||||
{"Top", SnapTop, 800, 600, 0, 0, screenW, halfH},
|
||||
{"Bottom", SnapBottom, 800, 600, 0, halfH, screenW, halfH},
|
||||
{"TopLeft", SnapTopLeft, 800, 600, 0, 0, halfW, halfH},
|
||||
{"TopRight", SnapTopRight, 800, 600, halfW, 0, halfW, halfH},
|
||||
{"BottomLeft", SnapBottomLeft, 800, 600, 0, halfH, halfW, halfH},
|
||||
{"BottomRight", SnapBottomRight, 800, 600, halfW, halfH, halfW, halfH},
|
||||
{"Center", SnapCenter, 800, 600, (screenW - 800) / 2, (screenH - 600) / 2, 800, 600},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
m, _ := newTestManager()
|
||||
_, err := m.Open(WithName("snap"), WithSize(tc.initW, tc.initH))
|
||||
require.NoError(t, err)
|
||||
|
||||
err = m.SnapWindow("snap", tc.pos, screenW, screenH)
|
||||
require.NoError(t, err)
|
||||
|
||||
pw, ok := m.Get("snap")
|
||||
require.True(t, ok)
|
||||
|
||||
x, y := pw.Position()
|
||||
w, h := pw.Size()
|
||||
assert.Equal(t, tc.wantX, x, "x position")
|
||||
assert.Equal(t, tc.wantY, y, "y position")
|
||||
assert.Equal(t, tc.wantWidth, w, "width")
|
||||
assert.Equal(t, tc.wantHeight, h, "height")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStackWindows_ThreeWindows_Good(t *testing.T) {
|
||||
m, _ := newTestManager()
|
||||
names := []string{"s1", "s2", "s3"}
|
||||
for _, name := range names {
|
||||
_, err := m.Open(WithName(name), WithSize(800, 600))
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
err := m.StackWindows(names, 30, 30)
|
||||
require.NoError(t, err)
|
||||
|
||||
for i, name := range names {
|
||||
pw, ok := m.Get(name)
|
||||
require.True(t, ok, "window %s should exist", name)
|
||||
x, y := pw.Position()
|
||||
assert.Equal(t, i*30, x, "window %s x position", name)
|
||||
assert.Equal(t, i*30, y, "window %s y position", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyWorkflow_AllLayouts_Good(t *testing.T) {
|
||||
const screenW, screenH = 1920, 1080
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
workflow WorkflowLayout
|
||||
// Expected positions/sizes for the first two windows.
|
||||
// For WorkflowSideBySide, TileWindows(LeftRight) divides equally.
|
||||
win0X, win0Y, win0W, win0H int
|
||||
win1X, win1Y, win1W, win1H int
|
||||
}{
|
||||
{
|
||||
"Coding",
|
||||
WorkflowCoding,
|
||||
0, 0, 1344, screenH, // 70% of 1920 = 1344
|
||||
1344, 0, screenW - 1344, screenH, // remaining 30%
|
||||
},
|
||||
{
|
||||
"Debugging",
|
||||
WorkflowDebugging,
|
||||
0, 0, 1152, screenH, // 60% of 1920 = 1152
|
||||
1152, 0, screenW - 1152, screenH, // remaining 40%
|
||||
},
|
||||
{
|
||||
"Presenting",
|
||||
WorkflowPresenting,
|
||||
0, 0, screenW, screenH, // maximised
|
||||
0, 0, 800, 600, // second window untouched
|
||||
},
|
||||
{
|
||||
"SideBySide",
|
||||
WorkflowSideBySide,
|
||||
0, 0, 960, screenH, // left half (1920/2)
|
||||
960, 0, 960, screenH, // right half
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
m, _ := newTestManager()
|
||||
_, err := m.Open(WithName("editor"), WithSize(800, 600))
|
||||
require.NoError(t, err)
|
||||
_, err = m.Open(WithName("terminal"), WithSize(800, 600))
|
||||
require.NoError(t, err)
|
||||
|
||||
err = m.ApplyWorkflow(tc.workflow, []string{"editor", "terminal"}, screenW, screenH)
|
||||
require.NoError(t, err)
|
||||
|
||||
pw0, ok := m.Get("editor")
|
||||
require.True(t, ok)
|
||||
x0, y0 := pw0.Position()
|
||||
w0, h0 := pw0.Size()
|
||||
assert.Equal(t, tc.win0X, x0, "editor x")
|
||||
assert.Equal(t, tc.win0Y, y0, "editor y")
|
||||
assert.Equal(t, tc.win0W, w0, "editor width")
|
||||
assert.Equal(t, tc.win0H, h0, "editor height")
|
||||
|
||||
pw1, ok := m.Get("terminal")
|
||||
require.True(t, ok)
|
||||
x1, y1 := pw1.Position()
|
||||
w1, h1 := pw1.Size()
|
||||
assert.Equal(t, tc.win1X, x1, "terminal x")
|
||||
assert.Equal(t, tc.win1Y, y1, "terminal y")
|
||||
assert.Equal(t, tc.win1W, w1, "terminal width")
|
||||
assert.Equal(t, tc.win1H, h1, "terminal height")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyWorkflow_Empty_Bad(t *testing.T) {
|
||||
m, _ := newTestManager()
|
||||
err := m.ApplyWorkflow(WorkflowCoding, []string{}, 1920, 1080)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue