From 35f8f5ec51913156cd69523227a93cd11e624e96 Mon Sep 17 00:00:00 2001 From: Virgil Date: Tue, 31 Mar 2026 05:31:00 +0000 Subject: [PATCH] refactor(ax): align GUI APIs with AX principles --- go.mod | 4 +- pkg/browser/register.go | 3 - pkg/browser/service.go | 6 - pkg/contextmenu/messages.go | 16 +- pkg/contextmenu/register.go | 9 +- pkg/contextmenu/service.go | 30 +-- pkg/contextmenu/service_test.go | 2 +- pkg/dialog/messages.go | 13 +- pkg/dialog/platform.go | 8 +- pkg/dialog/service.go | 13 +- pkg/dialog/service_test.go | 32 +-- pkg/display/display.go | 160 +++++++++------ pkg/display/display_test.go | 8 +- pkg/display/events.go | 16 +- pkg/dock/register.go | 3 - pkg/dock/service.go | 10 - pkg/keybinding/messages.go | 17 +- pkg/keybinding/register.go | 9 +- pkg/keybinding/service.go | 33 ++- pkg/keybinding/service_test.go | 2 +- pkg/lifecycle/register.go | 3 - pkg/lifecycle/service.go | 12 -- pkg/mcp/tools_clipboard.go | 10 +- pkg/mcp/tools_contextmenu.go | 18 +- pkg/mcp/tools_dialog.go | 22 +- pkg/mcp/tools_environment.go | 6 +- pkg/mcp/tools_layout.go | 6 +- pkg/mcp/tools_notification.go | 2 +- pkg/mcp/tools_screen.go | 12 +- pkg/mcp/tools_tray.go | 4 +- pkg/mcp/tools_webview.go | 16 +- pkg/mcp/tools_window.go | 16 +- pkg/menu/messages.go | 9 +- pkg/menu/register.go | 1 - pkg/menu/service.go | 22 +- pkg/notification/messages.go | 7 +- pkg/notification/platform.go | 2 +- pkg/notification/service.go | 33 ++- pkg/notification/service_test.go | 4 +- pkg/systray/menu.go | 6 +- pkg/systray/messages.go | 15 +- pkg/systray/mock_platform.go | 34 ++-- pkg/systray/mock_test.go | 18 +- pkg/systray/platform.go | 2 +- pkg/systray/register.go | 1 - pkg/systray/service.go | 17 +- pkg/systray/tray.go | 15 +- pkg/systray/tray_test.go | 23 +++ pkg/systray/wails.go | 16 +- pkg/webview/service.go | 42 ++-- pkg/window/layout.go | 14 +- pkg/window/messages.go | 48 +---- pkg/window/mock_platform.go | 54 ++--- pkg/window/mock_test.go | 57 +++--- pkg/window/options.go | 8 +- pkg/window/persistence_test.go | 334 +++++++++++++++++++++++++++++++ pkg/window/platform.go | 28 +-- pkg/window/register.go | 2 - pkg/window/service.go | 48 ++--- pkg/window/service_test.go | 255 ++++++++++++++++++++++- pkg/window/state.go | 44 +++- pkg/window/tiling.go | 12 +- pkg/window/wails.go | 55 +++-- pkg/window/window.go | 62 ++++-- pkg/window/window_test.go | 325 ++++++++++++++++++------------ 65 files changed, 1357 insertions(+), 777 deletions(-) create mode 100644 pkg/window/persistence_test.go diff --git a/go.mod b/go.mod index 62cc623..10f382e 100644 --- a/go.mod +++ b/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 diff --git a/pkg/browser/register.go b/pkg/browser/register.go index ff081e7..204686a 100644 --- a/pkg/browser/register.go +++ b/pkg/browser/register.go @@ -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{ diff --git a/pkg/browser/service.go b/pkg/browser/service.go index a3b8915..13000b9 100644 --- a/pkg/browser/service.go +++ b/pkg/browser/service.go @@ -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 } diff --git a/pkg/contextmenu/messages.go b/pkg/contextmenu/messages.go index cb62e17..c5f131f 100644 --- a/pkg/contextmenu/messages.go +++ b/pkg/contextmenu/messages.go @@ -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"` diff --git a/pkg/contextmenu/register.go b/pkg/contextmenu/register.go index afb0604..f0c3100 100644 --- a/pkg/contextmenu/register.go +++ b/pkg/contextmenu/register.go @@ -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 } } diff --git a/pkg/contextmenu/service.go b/pkg/contextmenu/service.go index 973346d..ba4884c 100644 --- a/pkg/contextmenu/service.go +++ b/pkg/contextmenu/service.go @@ -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 } diff --git a/pkg/contextmenu/service_test.go b/pkg/contextmenu/service_test.go index 93dd8d3..edab171 100644 --- a/pkg/contextmenu/service_test.go +++ b/pkg/contextmenu/service_test.go @@ -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) { diff --git a/pkg/dialog/messages.go b/pkg/dialog/messages.go index 131592e..c274f2c 100644 --- a/pkg/dialog/messages.go +++ b/pkg/dialog/messages.go @@ -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 } diff --git a/pkg/dialog/platform.go b/pkg/dialog/platform.go index 80b74d7..10585a4 100644 --- a/pkg/dialog/platform.go +++ b/pkg/dialog/platform.go @@ -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. diff --git a/pkg/dialog/service.go b/pkg/dialog/service.go index 231f3be..b9b23b5 100644 --- a/pkg/dialog/service.go +++ b/pkg/dialog/service.go @@ -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 diff --git a/pkg/dialog/service_test.go b/pkg/dialog/service_test.go index 66fe760..de476da 100644 --- a/pkg/dialog/service_test.go +++ b/pkg/dialog/service_test.go @@ -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"}, }, diff --git a/pkg/display/display.go b/pkg/display/display.go index 9b6b77a..d15226b 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -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"), diff --git a/pkg/display/display_test.go b/pkg/display/display_test.go index 03eb1d2..0c49729 100644 --- a/pkg/display/display_test.go +++ b/pkg/display/display_test.go @@ -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) diff --git a/pkg/display/events.go b/pkg/display/events.go index 823872f..6333c3c 100644 --- a/pkg/display/events.go +++ b/pkg/display/events.go @@ -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() } diff --git a/pkg/dock/register.go b/pkg/dock/register.go index 1123927..96ec94d 100644 --- a/pkg/dock/register.go +++ b/pkg/dock/register.go @@ -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{ diff --git a/pkg/dock/service.go b/pkg/dock/service.go index 260ff0a..346ef95 100644 --- a/pkg/dock/service.go +++ b/pkg/dock/service.go @@ -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: diff --git a/pkg/keybinding/messages.go b/pkg/keybinding/messages.go index 7f037f3..a168806 100644 --- a/pkg/keybinding/messages.go +++ b/pkg/keybinding/messages.go @@ -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"` } diff --git a/pkg/keybinding/register.go b/pkg/keybinding/register.go index 417819e..091cbfa 100644 --- a/pkg/keybinding/register.go +++ b/pkg/keybinding/register.go @@ -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 } } diff --git a/pkg/keybinding/service.go b/pkg/keybinding/service.go index 048c259..3afd23b 100644 --- a/pkg/keybinding/service.go +++ b/pkg/keybinding/service.go @@ -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 } diff --git a/pkg/keybinding/service_test.go b/pkg/keybinding/service_test.go index 14749f2..b586e07 100644 --- a/pkg/keybinding/service_test.go +++ b/pkg/keybinding/service_test.go @@ -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) { diff --git a/pkg/lifecycle/register.go b/pkg/lifecycle/register.go index 90e5d40..fcf43ea 100644 --- a/pkg/lifecycle/register.go +++ b/pkg/lifecycle/register.go @@ -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{ diff --git a/pkg/lifecycle/service.go b/pkg/lifecycle/service.go index 41e7ca8..3ba4255 100644 --- a/pkg/lifecycle/service.go +++ b/pkg/lifecycle/service.go @@ -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 } diff --git a/pkg/mcp/tools_clipboard.go b/pkg/mcp/tools_clipboard.go index 827586a..82aa435 100644 --- a/pkg/mcp/tools_clipboard.go +++ b/pkg/mcp/tools_clipboard.go @@ -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 } diff --git a/pkg/mcp/tools_contextmenu.go b/pkg/mcp/tools_contextmenu.go index 74e9f8b..d6da3a5 100644 --- a/pkg/mcp/tools_contextmenu.go +++ b/pkg/mcp/tools_contextmenu.go @@ -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 } diff --git a/pkg/mcp/tools_dialog.go b/pkg/mcp/tools_dialog.go index 06bdf66..aee701d 100644 --- a/pkg/mcp/tools_dialog.go +++ b/pkg/mcp/tools_dialog.go @@ -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 } diff --git a/pkg/mcp/tools_environment.go b/pkg/mcp/tools_environment.go index 87eb0df..c8fc831 100644 --- a/pkg/mcp/tools_environment.go +++ b/pkg/mcp/tools_environment.go @@ -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 } diff --git a/pkg/mcp/tools_layout.go b/pkg/mcp/tools_layout.go index 24ec8fa..18066d3 100644 --- a/pkg/mcp/tools_layout.go +++ b/pkg/mcp/tools_layout.go @@ -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 } diff --git a/pkg/mcp/tools_notification.go b/pkg/mcp/tools_notification.go index 259e59f..25c2d73 100644 --- a/pkg/mcp/tools_notification.go +++ b/pkg/mcp/tools_notification.go @@ -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, diff --git a/pkg/mcp/tools_screen.go b/pkg/mcp/tools_screen.go index 8a276b9..a89e879 100644 --- a/pkg/mcp/tools_screen.go +++ b/pkg/mcp/tools_screen.go @@ -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 } diff --git a/pkg/mcp/tools_tray.go b/pkg/mcp/tools_tray.go index 0cbad22..d5efb45 100644 --- a/pkg/mcp/tools_tray.go +++ b/pkg/mcp/tools_tray.go @@ -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 } diff --git a/pkg/mcp/tools_webview.go b/pkg/mcp/tools_webview.go index b598a4b..923fe36 100644 --- a/pkg/mcp/tools_webview.go +++ b/pkg/mcp/tools_webview.go @@ -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 } diff --git a/pkg/mcp/tools_window.go b/pkg/mcp/tools_window.go index e5ac73f..e10c349 100644 --- a/pkg/mcp/tools_window.go +++ b/pkg/mcp/tools_window.go @@ -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 } diff --git a/pkg/menu/messages.go b/pkg/menu/messages.go index 61c8de5..55aed57 100644 --- a/pkg/menu/messages.go +++ b/pkg/menu/messages.go @@ -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 } diff --git a/pkg/menu/register.go b/pkg/menu/register.go index acb4b88..59dbae8 100644 --- a/pkg/menu/register.go +++ b/pkg/menu/register.go @@ -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{ diff --git a/pkg/menu/service.go b/pkg/menu/service.go index 2e8ac26..1a3f838 100644 --- a/pkg/menu/service.go +++ b/pkg/menu/service.go @@ -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 } diff --git a/pkg/notification/messages.go b/pkg/notification/messages.go index e0df1ea..1cc10f9 100644 --- a/pkg/notification/messages.go +++ b/pkg/notification/messages.go @@ -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 } diff --git a/pkg/notification/platform.go b/pkg/notification/platform.go index f0d9963..954a5af 100644 --- a/pkg/notification/platform.go +++ b/pkg/notification/platform.go @@ -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) } diff --git a/pkg/notification/service.go b/pkg/notification/service.go index df43b6d..7dc412b 100644 --- a/pkg/notification/service.go +++ b/pkg/notification/service.go @@ -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"}, }, diff --git a/pkg/notification/service_test.go b/pkg/notification/service_test.go index 33db648..8689ddf 100644 --- a/pkg/notification/service_test.go +++ b/pkg/notification/service_test.go @@ -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 diff --git a/pkg/systray/menu.go b/pkg/systray/menu.go index 3032a6d..df2bdaa 100644 --- a/pkg/systray/menu.go +++ b/pkg/systray/menu.go @@ -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) diff --git a/pkg/systray/messages.go b/pkg/systray/messages.go index 4fc5bfe..6855e22 100644 --- a/pkg/systray/messages.go +++ b/pkg/systray/messages.go @@ -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 } diff --git a/pkg/systray/mock_platform.go b/pkg/systray/mock_platform.go index 0f3f6e1..c92a58f 100644 --- a/pkg/systray/mock_platform.go +++ b/pkg/systray/mock_platform.go @@ -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 } diff --git a/pkg/systray/mock_test.go b/pkg/systray/mock_test.go index 9082805..56f35cf 100644 --- a/pkg/systray/mock_test.go +++ b/pkg/systray/mock_test.go @@ -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 } diff --git a/pkg/systray/platform.go b/pkg/systray/platform.go index 1d76ec5..b749422 100644 --- a/pkg/systray/platform.go +++ b/pkg/systray/platform.go @@ -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. diff --git a/pkg/systray/register.go b/pkg/systray/register.go index b4d133b..055f35c 100644 --- a/pkg/systray/register.go +++ b/pkg/systray/register.go @@ -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{ diff --git a/pkg/systray/service.go b/pkg/systray/service.go index 70eaa04..f585e7e 100644 --- a/pkg/systray/service.go +++ b/pkg/systray/service.go @@ -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 } diff --git a/pkg/systray/tray.go b/pkg/systray/tray.go index 05ffcdf..8d2e108 100644 --- a/pkg/systray/tray.go +++ b/pkg/systray/tray.go @@ -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 diff --git a/pkg/systray/tray_test.go b/pkg/systray/tray_test.go index f802828..68b7feb 100644 --- a/pkg/systray/tray_test.go +++ b/pkg/systray/tray_test.go @@ -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]) +} diff --git a/pkg/systray/wails.go b/pkg/systray/wails.go index cbd9ed2..47b6982 100644 --- a/pkg/systray/wails.go +++ b/pkg/systray/wails.go @@ -28,9 +28,9 @@ type wailsTray struct { } func (wt *wailsTray) SetIcon(data []byte) { wt.tray.SetIcon(data) } -func (wt *wailsTray) SetTemplateIcon(data []byte) { wt.tray.SetTemplateIcon(data) } -func (wt *wailsTray) SetTooltip(text string) { wt.tray.SetTooltip(text) } -func (wt *wailsTray) SetLabel(text string) { wt.tray.SetLabel(text) } +func (wt *wailsTray) SetTemplateIcon(data []byte) { wt.tray.SetTemplateIcon(data) } +func (wt *wailsTray) SetTooltip(text string) { wt.tray.SetTooltip(text) } +func (wt *wailsTray) SetLabel(text string) { wt.tray.SetLabel(text) } func (wt *wailsTray) SetMenu(menu PlatformMenu) { if wm, ok := menu.(*wailsTrayMenu); ok { @@ -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()} -} diff --git a/pkg/webview/service.go b/pkg/webview/service.go index 6713174..b6e468b 100644 --- a/pkg/webview/service.go +++ b/pkg/webview/service.go @@ -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 { diff --git a/pkg/window/layout.go b/pkg/window/layout.go index 545a99f..7021a72 100644 --- a/pkg/window/layout.go +++ b/pkg/window/layout.go @@ -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() diff --git a/pkg/window/messages.go b/pkg/window/messages.go index b5d1a13..ece680a 100644 --- a/pkg/window/messages.go +++ b/pkg/window/messages.go @@ -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"` } diff --git a/pkg/window/mock_platform.go b/pkg/window/mock_platform.go index 9dde9a6..1d3176c 100644 --- a/pkg/window/mock_platform.go +++ b/pkg/window/mock_platform.go @@ -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) } diff --git a/pkg/window/mock_test.go b/pkg/window/mock_test.go index 72d54ca..0babb6a 100644 --- a/pkg/window/mock_test.go +++ b/pkg/window/mock_test.go @@ -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) } diff --git a/pkg/window/options.go b/pkg/window/options.go index b677617..38c5064 100644 --- a/pkg/window/options.go +++ b/pkg/window/options.go @@ -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 } } diff --git a/pkg/window/persistence_test.go b/pkg/window/persistence_test.go new file mode 100644 index 0000000..ba7eaee --- /dev/null +++ b/pkg/window/persistence_test.go @@ -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) +} diff --git a/pkg/window/platform.go b/pkg/window/platform.go index ae4e2e6..c0e56a9 100644 --- a/pkg/window/platform.go +++ b/pkg/window/platform.go @@ -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. diff --git a/pkg/window/register.go b/pkg/window/register.go index 63812f1..850b57a 100644 --- a/pkg/window/register.go +++ b/pkg/window/register.go @@ -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{ diff --git a/pkg/window/service.go b/pkg/window/service.go index 040ab95..877c43f 100644 --- a/pkg/window/service.go +++ b/pkg/window/service.go @@ -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 } diff --git a/pkg/window/service_test.go b/pkg/window/service_test.go index 1911044..57fb9f4 100644 --- a/pkg/window/service_test.go +++ b/pkg/window/service_test.go @@ -31,7 +31,7 @@ func TestRegister_Good(t *testing.T) { func TestTaskOpenWindow_Good(t *testing.T) { _, c := newTestWindowService(t) result, handled, err := c.PERFORM(TaskOpenWindow{ - 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) +} diff --git a/pkg/window/state.go b/pkg/window/state.go index 3523cfe..1b84d07 100644 --- a/pkg/window/state.go +++ b/pkg/window/state.go @@ -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() { diff --git a/pkg/window/tiling.go b/pkg/window/tiling.go index 40669fe..e6ee430 100644 --- a/pkg/window/tiling.go +++ b/pkg/window/tiling.go @@ -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 { diff --git a/pkg/window/wails.go b/pkg/window/wails.go index 1d2a722..a2587ce 100644 --- a/pkg/window/wails.go +++ b/pkg/window/wails.go @@ -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) - diff --git a/pkg/window/window.go b/pkg/window/window.go index 3692fe8..5e73e59 100644 --- a/pkg/window/window.go +++ b/pkg/window/window.go @@ -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 = "/" diff --git a/pkg/window/window_test.go b/pkg/window/window_test.go index 44d1f09..f75fe46 100644 --- a/pkg/window/window_test.go +++ b/pkg/window/window_test.go @@ -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) +}