diff --git a/pkg/contextmenu/service.go b/pkg/contextmenu/service.go index ba4884c..f6d97e4 100644 --- a/pkg/contextmenu/service.go +++ b/pkg/contextmenu/service.go @@ -3,8 +3,8 @@ package contextmenu import ( "context" - "fmt" + coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/go/pkg/core" ) @@ -84,7 +84,7 @@ func (s *Service) taskAdd(t TaskAdd) error { }) }) if err != nil { - return fmt.Errorf("contextmenu: platform add failed: %w", err) + return coreerr.E("contextmenu.taskAdd", "platform add failed", err) } s.registeredMenus[t.Name] = t.Menu @@ -98,7 +98,7 @@ func (s *Service) taskRemove(t TaskRemove) error { err := s.platform.Remove(t.Name) if err != nil { - return fmt.Errorf("contextmenu: platform remove failed: %w", err) + return coreerr.E("contextmenu.taskRemove", "platform remove failed", err) } delete(s.registeredMenus, t.Name) diff --git a/pkg/display/README.md b/pkg/display/README.md index f8f692b..740e5ef 100644 --- a/pkg/display/README.md +++ b/pkg/display/README.md @@ -1,43 +1,30 @@ # Display -This repository is a display module for the core web3 framework. It includes a Go backend, an Angular custom element, and a full release cycle configuration. +`pkg/display` is the Core GUI display service. It owns window orchestration, layouts, menus, system tray state, dialogs, notifications, and the IPC bridge to the frontend. -## Getting Started +## Working Locally -1. **Clone the repository:** - ```bash - git clone https://github.com/Snider/display.git - ``` +1. Run the backend tests: + ```bash + go test ./pkg/display/... + ``` +2. Run the full workspace tests when you touch IPC contracts: + ```bash + go test ./... + ``` +3. Build the Angular frontend: + ```bash + cd ui + npm install + npm run build + ``` -2. **Install the dependencies:** - ```bash - cd display - go mod tidy - cd ui - npm install - ``` +## Declarative Windows -3. **Run the development server:** - ```bash - go run ./cmd/demo-cli serve - ``` - This will start the Go backend and serve the Angular custom element. +Windows are created from a `window.Window` spec instead of a fluent option chain: -## Building the Custom Element - -To build the Angular custom element, run the following command: - -```bash -cd ui -npm run build +```go +svc.OpenWindow(window.Window{Name: "editor", Title: "Editor", URL: "/#/editor"}) ``` -This will create a single JavaScript file in the `dist` directory that you can use in any HTML page. - -## Contributing - -Contributions are welcome! Please feel free to submit a Pull Request. - -## License - -This project is licensed under the EUPL-1.2 License - see the [LICENSE](LICENSE) file for details. +The same spec is used by `CreateWindow`, layout restore, tiling, snapping, and workflow presets. diff --git a/pkg/display/display.go b/pkg/display/display.go index 99d73ba..66b18bc 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -578,11 +578,22 @@ func (s *Service) persistSection(key string, value map[string]any) { // --- Service accessors --- +func (s *Service) performWindowTask(operation string, task core.Task) (any, error) { + result, handled, err := s.Core().PERFORM(task) + if err != nil { + return nil, err + } + if !handled { + return nil, coreerr.E(operation, "window service not available", nil) + } + return result, nil +} + // --- Window Management (delegates via IPC) --- // OpenWindow creates a new window from a declarative Window spec. func (s *Service) OpenWindow(spec window.Window) error { - _, _, err := s.Core().PERFORM(window.TaskOpenWindow{Window: spec}) + _, err := s.performWindowTask("display.OpenWindow", window.TaskOpenWindow{Window: spec}) return err } @@ -603,7 +614,7 @@ func (s *Service) GetWindowInfo(name string) (*window.WindowInfo, error) { func (s *Service) ListWindowInfos() []window.WindowInfo { result, handled, _ := s.Core().QUERY(window.QueryWindowList{}) if !handled { - return nil + return []window.WindowInfo{} } list, _ := result.([]window.WindowInfo) return list @@ -611,82 +622,82 @@ func (s *Service) ListWindowInfos() []window.WindowInfo { // SetWindowPosition moves a window via IPC. func (s *Service) SetWindowPosition(name string, x, y int) error { - _, _, err := s.Core().PERFORM(window.TaskSetPosition{Name: name, X: x, Y: y}) + _, err := s.performWindowTask("display.SetWindowPosition", window.TaskSetPosition{Name: name, X: x, Y: y}) return err } // SetWindowSize resizes a window via IPC. func (s *Service) SetWindowSize(name string, width, height int) error { - _, _, err := s.Core().PERFORM(window.TaskSetSize{Name: name, Width: width, Height: height}) + _, err := s.performWindowTask("display.SetWindowSize", window.TaskSetSize{Name: name, Width: width, Height: height}) return err } // SetWindowBounds sets both position and size of a window via IPC. 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 { + if _, err := s.performWindowTask("display.SetWindowBounds", window.TaskSetPosition{Name: name, X: x, Y: y}); err != nil { return err } - _, _, err := s.Core().PERFORM(window.TaskSetSize{Name: name, Width: width, Height: height}) + _, err := s.performWindowTask("display.SetWindowBounds", window.TaskSetSize{Name: name, Width: width, Height: height}) return err } // MaximizeWindow maximizes a window via IPC. func (s *Service) MaximizeWindow(name string) error { - _, _, err := s.Core().PERFORM(window.TaskMaximise{Name: name}) + _, err := s.performWindowTask("display.MaximizeWindow", window.TaskMaximise{Name: name}) return err } // MinimizeWindow minimizes a window via IPC. func (s *Service) MinimizeWindow(name string) error { - _, _, err := s.Core().PERFORM(window.TaskMinimise{Name: name}) + _, err := s.performWindowTask("display.MinimizeWindow", window.TaskMinimise{Name: name}) return err } // FocusWindow brings a window to the front via IPC. func (s *Service) FocusWindow(name string) error { - _, _, err := s.Core().PERFORM(window.TaskFocus{Name: name}) + _, err := s.performWindowTask("display.FocusWindow", window.TaskFocus{Name: name}) return err } // CloseWindow closes a window via IPC. func (s *Service) CloseWindow(name string) error { - _, _, err := s.Core().PERFORM(window.TaskCloseWindow{Name: name}) + _, err := s.performWindowTask("display.CloseWindow", window.TaskCloseWindow{Name: name}) return err } // RestoreWindow restores a maximized/minimized window. func (s *Service) RestoreWindow(name string) error { - _, _, err := s.Core().PERFORM(window.TaskRestore{Name: name}) + _, err := s.performWindowTask("display.RestoreWindow", window.TaskRestore{Name: name}) return err } // SetWindowVisibility shows or hides a window. func (s *Service) SetWindowVisibility(name string, visible bool) error { - _, _, err := s.Core().PERFORM(window.TaskSetVisibility{Name: name, Visible: visible}) + _, err := s.performWindowTask("display.SetWindowVisibility", window.TaskSetVisibility{Name: name, Visible: visible}) return err } // SetWindowAlwaysOnTop sets whether a window stays on top. func (s *Service) SetWindowAlwaysOnTop(name string, alwaysOnTop bool) error { - _, _, err := s.Core().PERFORM(window.TaskSetAlwaysOnTop{Name: name, AlwaysOnTop: alwaysOnTop}) + _, err := s.performWindowTask("display.SetWindowAlwaysOnTop", window.TaskSetAlwaysOnTop{Name: name, AlwaysOnTop: alwaysOnTop}) return err } // SetWindowTitle changes a window's title. func (s *Service) SetWindowTitle(name string, title string) error { - _, _, err := s.Core().PERFORM(window.TaskSetTitle{Name: name, Title: title}) + _, err := s.performWindowTask("display.SetWindowTitle", window.TaskSetTitle{Name: name, Title: title}) return err } // SetWindowFullscreen sets a window to fullscreen mode. func (s *Service) SetWindowFullscreen(name string, fullscreen bool) error { - _, _, err := s.Core().PERFORM(window.TaskFullscreen{Name: name, Fullscreen: fullscreen}) + _, err := s.performWindowTask("display.SetWindowFullscreen", window.TaskFullscreen{Name: name, Fullscreen: fullscreen}) return err } // SetWindowBackgroundColour sets the background colour of a window. func (s *Service) SetWindowBackgroundColour(name string, r, g, b, a uint8) error { - _, _, err := s.Core().PERFORM(window.TaskSetBackgroundColour{ + _, err := s.performWindowTask("display.SetWindowBackgroundColour", window.TaskSetBackgroundColour{ Name: name, Red: r, Green: g, Blue: b, Alpha: a, }) return err @@ -717,19 +728,19 @@ func (s *Service) GetWindowTitle(name string) (string, error) { // ResetWindowState clears saved window positions. func (s *Service) ResetWindowState() error { - _, _, _ = s.Core().PERFORM(window.TaskResetWindowState{}) - return nil + _, err := s.performWindowTask("display.ResetWindowState", window.TaskResetWindowState{}) + return err } // GetSavedWindowStates returns all saved window states. func (s *Service) GetSavedWindowStates() map[string]window.WindowState { result, handled, _ := s.Core().QUERY(window.QuerySavedWindowStates{}) if !handled { - return nil + return map[string]window.WindowState{} } saved, _ := result.(map[string]window.WindowState) if saved == nil { - return nil + return map[string]window.WindowState{} } out := make(map[string]window.WindowState, len(saved)) for name, state := range saved { @@ -743,13 +754,16 @@ func (s *Service) CreateWindow(spec window.Window) (*window.WindowInfo, error) { if spec.Name == "" { return nil, coreerr.E("display.CreateWindow", "window name is required", nil) } - result, _, err := s.Core().PERFORM(window.TaskOpenWindow{ + result, err := s.performWindowTask("display.CreateWindow", window.TaskOpenWindow{ Window: spec, }) if err != nil { return nil, err } - info := result.(window.WindowInfo) + info, ok := result.(window.WindowInfo) + if !ok { + return nil, coreerr.E("display.CreateWindow", "unexpected result type from window create task", nil) + } return &info, nil } @@ -757,13 +771,13 @@ func (s *Service) CreateWindow(spec window.Window) (*window.WindowInfo, error) { // SaveLayout saves the current window arrangement as a named layout. func (s *Service) SaveLayout(name string) error { - _, _, err := s.Core().PERFORM(window.TaskSaveLayout{Name: name}) + _, err := s.performWindowTask("display.SaveLayout", window.TaskSaveLayout{Name: name}) return err } // RestoreLayout applies a saved layout. func (s *Service) RestoreLayout(name string) error { - _, _, err := s.Core().PERFORM(window.TaskRestoreLayout{Name: name}) + _, err := s.performWindowTask("display.RestoreLayout", window.TaskRestoreLayout{Name: name}) return err } @@ -771,7 +785,7 @@ func (s *Service) RestoreLayout(name string) error { func (s *Service) ListLayouts() []window.LayoutInfo { result, handled, _ := s.Core().QUERY(window.QueryLayoutList{}) if !handled { - return nil + return []window.LayoutInfo{} } layouts, _ := result.([]window.LayoutInfo) return layouts @@ -779,7 +793,7 @@ func (s *Service) ListLayouts() []window.LayoutInfo { // DeleteLayout removes a saved layout by name. func (s *Service) DeleteLayout(name string) error { - _, _, err := s.Core().PERFORM(window.TaskDeleteLayout{Name: name}) + _, err := s.performWindowTask("display.DeleteLayout", window.TaskDeleteLayout{Name: name}) return err } @@ -797,25 +811,25 @@ func (s *Service) GetLayout(name string) *window.Layout { // TileWindows arranges windows in a tiled layout. func (s *Service) TileWindows(mode window.TileMode, windowNames []string) error { - _, _, err := s.Core().PERFORM(window.TaskTileWindows{Mode: mode.String(), Windows: windowNames}) + _, err := s.performWindowTask("display.TileWindows", window.TaskTileWindows{Mode: mode.String(), Windows: windowNames}) return err } // SnapWindow snaps a window to a screen edge or corner. func (s *Service) SnapWindow(name string, position window.SnapPosition) error { - _, _, err := s.Core().PERFORM(window.TaskSnapWindow{Name: name, Position: position.String()}) + _, err := s.performWindowTask("display.SnapWindow", window.TaskSnapWindow{Name: name, Position: position.String()}) return err } // StackWindows arranges windows in a cascade pattern. func (s *Service) StackWindows(windowNames []string, offsetX, offsetY int) error { - _, _, err := s.Core().PERFORM(window.TaskStackWindows{Windows: windowNames, OffsetX: offsetX, OffsetY: offsetY}) + _, err := s.performWindowTask("display.StackWindows", window.TaskStackWindows{Windows: windowNames, OffsetX: offsetX, OffsetY: offsetY}) return err } // ApplyWorkflowLayout applies a predefined layout for a specific workflow. func (s *Service) ApplyWorkflowLayout(workflow window.WorkflowLayout) error { - _, _, err := s.Core().PERFORM(window.TaskApplyWorkflow{ + _, err := s.performWindowTask("display.ApplyWorkflowLayout", window.TaskApplyWorkflow{ Workflow: workflow.String(), }) return err @@ -878,15 +892,15 @@ func (s *Service) handleNewWorkspace() { } func (s *Service) handleListWorkspaces() { - ws := s.Core().Service("workspace") - if ws == nil { + workspaceService := s.Core().Service("workspace") + if workspaceService == nil { return } - lister, ok := ws.(interface{ ListWorkspaces() []string }) + workspaceLister, ok := workspaceService.(interface{ ListWorkspaces() []string }) if !ok { return } - _ = lister.ListWorkspaces() + _ = workspaceLister.ListWorkspaces() } func (s *Service) handleNewFile() { diff --git a/pkg/display/display_test.go b/pkg/display/display_test.go index d67c26f..59601ae 100644 --- a/pkg/display/display_test.go +++ b/pkg/display/display_test.go @@ -389,6 +389,14 @@ func TestCreateWindow_Bad(t *testing.T) { assert.Contains(t, err.Error(), "window name is required") } +func TestCreateWindow_Bad_NoWindowService(t *testing.T) { + svc, _ := newTestDisplayService(t) + + _, err := svc.CreateWindow(window.Window{Name: "orphan-window"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "window service not available") +} + func TestResetWindowState_Good(t *testing.T) { c := newTestConclave(t) svc := core.MustServiceFor[*Service](c, "display") diff --git a/pkg/display/docs/backend.md b/pkg/display/docs/backend.md index 3e67e43..853ec7b 100644 --- a/pkg/display/docs/backend.md +++ b/pkg/display/docs/backend.md @@ -1,6 +1,6 @@ # Backend Documentation -The backend is written in Go and uses the `github.com/Snider/display` package. It utilizes the Wails v3 framework to bridge Go and the web frontend. +The backend is written in Go and uses the `forge.lthn.ai/core/gui/pkg/display` package. It uses Wails v3 to bridge Go and the web frontend. ## Core Types @@ -9,46 +9,53 @@ The `Service` struct is the main entry point for the display logic. - **Initialization:** - `New() (*Service, error)`: Creates a new instance of the service. - - `Startup(ctx context.Context) error`: Initializes the Wails application, builds the menu, sets up the system tray, and opens the main window. + - `Register(wailsApp *application.App) func(*core.Core) (any, error)`: Captures the Wails app and registers the service with Core. + - `OnStartup(ctx context.Context) error`: Loads config and registers IPC handlers. - **Window Management:** - - `OpenWindow(opts ...WindowOption) error`: Opens a new window with the specified options. + - `OpenWindow(spec window.Window) error`: Opens a new window from a declarative spec. + - `CreateWindow(spec window.Window) (*window.WindowInfo, error)`: Opens a window and returns its info. + - `GetWindowInfo(name string) (*window.WindowInfo, error)`: Queries a single window. + - `ListWindowInfos() []window.WindowInfo`: Queries all tracked windows. + - `SetWindowPosition(name string, x, y int) error` + - `SetWindowSize(name string, width, height int) error` + - `SetWindowBounds(name string, x, y, width, height int) error` + - `MaximizeWindow(name string) error` + - `MinimizeWindow(name string) error` + - `RestoreWindow(name string) error` + - `FocusWindow(name string) error` + - `SetWindowFullscreen(name string, fullscreen bool) error` + - `SetWindowAlwaysOnTop(name string, alwaysOnTop bool) error` + - `SetWindowVisibility(name string, visible bool) error` + - `SetWindowTitle(name, title string) error` + - `SetWindowBackgroundColour(name string, r, g, b, a uint8) error` + - `GetFocusedWindow() string` + - `ResetWindowState() error` + - `GetSavedWindowStates() map[string]window.WindowState` -- **Dialogs:** - - `ShowEnvironmentDialog()`: Displays a native dialog containing information about the runtime environment (OS, Arch, Debug mode, etc.). +Example: -### `WindowConfig` & `WindowOption` -Window configuration is handled using the Functional Options pattern. The `WindowConfig` struct holds parameters like: -- `Name`, `Title` -- `Width`, `Height` -- `URL` -- `AlwaysOnTop`, `Hidden`, `Frameless` -- Window button states (`MinimiseButtonState`, `MaximiseButtonState`, `CloseButtonState`) - -**Available Options:** -- `WithName(name string)` -- `WithTitle(title string)` -- `WithWidth(width int)` -- `WithHeight(height int)` -- `WithURL(url string)` -- `WithAlwaysOnTop(bool)` -- `WithHidden(bool)` -- `WithFrameless(bool)` -- `WithMinimiseButtonState(state)` -- `WithMaximiseButtonState(state)` -- `WithCloseButtonState(state)` +```go +svc.OpenWindow(window.Window{ + Name: "editor", + Title: "Editor", + URL: "/#/editor", + Width: 1200, + Height: 800, +}) +``` ## Subsystems ### Menu (`menu.go`) -The `buildMenu` method constructs the application's main menu, adding standard roles like File, Edit, Window, and Help. It allows for platform-specific adjustments (e.g., AppMenu on macOS). +The `buildMenu()` method constructs the application's main menu, adding standard roles like File, Edit, Window, and Help. It also dispatches the app-specific developer actions used by the frontend. ### System Tray (`tray.go`) -The `systemTray` method initializes the system tray icon and its context menu. It supports: +The `setupTray()` method initializes the system tray icon and its context menu. It supports: - Showing/Hiding all windows. - Displaying environment info. - Quitting the application. -- Attaching a hidden window for advanced tray interactions. +- Attaching tray actions that are broadcast as IPC events. -### Actions (`actions.go`) -Defines structured messages for Inter-Process Communication (IPC) or internal event handling, such as `ActionOpenWindow` which wraps `application.WebviewWindowOptions`. +### Actions (`messages.go`) +Defines structured messages for Inter-Process Communication (IPC) and internal event handling, including `window.ActionWindowOpened`, `window.ActionWindowClosed`, and `display.ActionIDECommand`. diff --git a/pkg/display/docs/development.md b/pkg/display/docs/development.md index 720fa81..4a0e3dc 100644 --- a/pkg/display/docs/development.md +++ b/pkg/display/docs/development.md @@ -1,6 +1,6 @@ # Development Guide -This guide covers how to set up the development environment, build the project, and run the demo. +This guide covers how to set up the development environment, build the project, and run the tests. ## Prerequisites @@ -12,11 +12,7 @@ This guide covers how to set up the development environment, build the project, ## Setup -1. Clone the repository: - ```bash - git clone https://github.com/Snider/display.git - cd display - ``` +1. Clone the repository and enter the workspace. 2. Install Go dependencies: ```bash @@ -30,23 +26,6 @@ This guide covers how to set up the development environment, build the project, cd .. ``` -## Running the Demo - -The project includes a CLI to facilitate development. - -### Serve Mode (Web Preview) -To start a simple HTTP server that serves the frontend and a mock API: - -1. Build the frontend first: - ```bash - cd ui && npm run build && cd .. - ``` -2. Run the serve command: - ```bash - go run ./cmd/demo-cli serve - ``` - Access the app at `http://localhost:8080`. - ## Building the Project ### Frontend @@ -56,9 +35,9 @@ npm run build ``` ### Backend / Application -This project is a library/module. However, it can be tested via the demo CLI or by integrating it into a Wails application entry point. +This package is exercised through Go tests and the host application that embeds it. To run the tests: ```bash -go test ./... +go test ./pkg/display/... ``` diff --git a/pkg/display/docs/overview.md b/pkg/display/docs/overview.md index ef743b2..e236d73 100644 --- a/pkg/display/docs/overview.md +++ b/pkg/display/docs/overview.md @@ -1,25 +1,24 @@ # Overview -The `display` module is a core component responsible for the visual presentation and system integration of the application. It leverages **Wails v3** to create a desktop application backend in Go and **Angular** for the frontend user interface. +The `display` module is the Core GUI surface. It coordinates windows, menus, trays, dialogs, notifications, and WebSocket events through **Wails v3** on the Go side and **Angular** in `ui/` on the frontend side. ## Architecture The project consists of two main parts: -1. **Backend (Go):** Handles window management, system tray integration, application menus, and communication with the frontend. It is located in the root directory and packaged as a Go module. -2. **Frontend (Angular):** Provides the user interface. It is located in the `ui/` directory and is built as a custom element that interacts with the backend. +1. **Backend (Go):** Handles window management, tray/menu setup, dialogs, notifications, layout persistence, and IPC dispatch. +2. **Frontend (Angular):** Provides the user interface. It lives in `ui/` and is built as a custom element that talks to the backend through the Wails runtime. ## Key Components ### Display Service (`display`) -The core service that manages the application lifecycle. It wraps the Wails application instance and exposes methods to: -- Open and configure windows. -- Manage the system tray. -- Show system dialogs (e.g., environment info). +The core service manages the application lifecycle and exposes declarative operations such as: +- `OpenWindow(window.Window{Name: "editor", URL: "/#/editor"})` +- `SaveLayout("coding")` +- `TileWindows(window.TileModeLeftRight, []string{"editor", "terminal"})` +- `ApplyWorkflowLayout(window.WorkflowCoding)` ### System Integration -- **Menu:** A standard application menu (File, Edit, View, etc.) is constructed in `menu.go`. -- **System Tray:** A system tray icon and menu are configured in `tray.go`, allowing quick access to common actions like showing/hiding windows or viewing environment info. - -### Demo CLI -A command-line interface (`cmd/demo-cli`) is provided to run and test the display module. It includes a `serve` command for web-based development. +- **Menu:** The application menu is constructed in `buildMenu()` and dispatched through IPC. +- **System Tray:** The tray menu is configured in `setupTray()` and keeps the desktop surface in sync with the runtime. +- **Events:** Window, theme, screen, lifecycle, and tray actions are broadcast as WebSocket events for the frontend. diff --git a/pkg/mcp/tools_notification.go b/pkg/mcp/tools_notification.go index 25c2d73..11e8af3 100644 --- a/pkg/mcp/tools_notification.go +++ b/pkg/mcp/tools_notification.go @@ -3,8 +3,8 @@ package mcp import ( "context" - "fmt" + coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/gui/pkg/notification" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -46,7 +46,7 @@ func (s *Subsystem) notificationPermissionRequest(_ context.Context, _ *mcp.Call } granted, ok := result.(bool) if !ok { - return nil, NotificationPermissionRequestOutput{}, fmt.Errorf("unexpected result type from notification permission request") + return nil, NotificationPermissionRequestOutput{}, coreerr.E("mcp.notificationPermissionRequest", "unexpected result type from notification permission request", nil) } return nil, NotificationPermissionRequestOutput{Granted: granted}, nil } @@ -65,7 +65,7 @@ func (s *Subsystem) notificationPermissionCheck(_ context.Context, _ *mcp.CallTo } status, ok := result.(notification.PermissionStatus) if !ok { - return nil, NotificationPermissionCheckOutput{}, fmt.Errorf("unexpected result type from notification permission check") + return nil, NotificationPermissionCheckOutput{}, coreerr.E("mcp.notificationPermissionCheck", "unexpected result type from notification permission check", nil) } return nil, NotificationPermissionCheckOutput{Granted: status.Granted}, nil } diff --git a/pkg/mcp/tools_window.go b/pkg/mcp/tools_window.go index ac14cd2..ccf4ff3 100644 --- a/pkg/mcp/tools_window.go +++ b/pkg/mcp/tools_window.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" ) @@ -23,7 +23,7 @@ func (s *Subsystem) windowList(_ context.Context, _ *mcp.CallToolRequest, _ Wind } windows, ok := result.([]window.WindowInfo) if !ok { - return nil, WindowListOutput{}, fmt.Errorf("unexpected result type from window list query") + return nil, WindowListOutput{}, coreerr.E("mcp.windowList", "unexpected result type from window list query", nil) } return nil, WindowListOutput{Windows: windows}, nil } @@ -44,7 +44,7 @@ func (s *Subsystem) windowGet(_ context.Context, _ *mcp.CallToolRequest, input W } info, ok := result.(*window.WindowInfo) if !ok { - return nil, WindowGetOutput{}, fmt.Errorf("unexpected result type from window get query") + return nil, WindowGetOutput{}, coreerr.E("mcp.windowGet", "unexpected result type from window get query", nil) } return nil, WindowGetOutput{Window: info}, nil } @@ -63,7 +63,7 @@ func (s *Subsystem) windowFocused(_ context.Context, _ *mcp.CallToolRequest, _ W } windows, ok := result.([]window.WindowInfo) if !ok { - return nil, WindowFocusedOutput{}, fmt.Errorf("unexpected result type from window list query") + return nil, WindowFocusedOutput{}, coreerr.E("mcp.windowFocused", "unexpected result type from window list query", nil) } for _, w := range windows { if w.Focused { @@ -88,7 +88,7 @@ func (s *Subsystem) windowCreate(_ context.Context, _ *mcp.CallToolRequest, inpu } info, ok := result.(window.WindowInfo) if !ok { - return nil, WindowCreateOutput{}, fmt.Errorf("unexpected result type from window create task") + return nil, WindowCreateOutput{}, coreerr.E("mcp.windowCreate", "unexpected result type from window create task", nil) } return nil, WindowCreateOutput{Window: info}, nil } diff --git a/pkg/systray/menu.go b/pkg/systray/menu.go index 848ee1a..c56ebc9 100644 --- a/pkg/systray/menu.go +++ b/pkg/systray/menu.go @@ -1,12 +1,12 @@ // pkg/systray/menu.go package systray -import "fmt" +import coreerr "forge.lthn.ai/core/go-log" // SetMenu sets a dynamic menu on the tray from TrayMenuItem descriptors. func (m *Manager) SetMenu(items []TrayMenuItem) error { if m.tray == nil { - return fmt.Errorf("tray not initialised") + return coreerr.E("systray.Manager.SetMenu", "tray not initialised", nil) } menu := m.platform.NewMenu() m.buildMenu(menu, items) diff --git a/pkg/window/layout.go b/pkg/window/layout.go index 7021a72..2871937 100644 --- a/pkg/window/layout.go +++ b/pkg/window/layout.go @@ -5,6 +5,7 @@ import ( "encoding/json" "os" "path/filepath" + "sort" "sync" "time" @@ -133,6 +134,9 @@ func (lm *LayoutManager) ListLayouts() []LayoutInfo { CreatedAt: l.CreatedAt, UpdatedAt: l.UpdatedAt, }) } + sort.Slice(infos, func(i, j int) bool { + return infos[i].Name < infos[j].Name + }) return infos } diff --git a/pkg/window/service.go b/pkg/window/service.go index 14bd0fa..27550fc 100644 --- a/pkg/window/service.go +++ b/pkg/window/service.go @@ -2,8 +2,8 @@ package window import ( "context" - "fmt" + coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/go/pkg/core" "forge.lthn.ai/core/gui/pkg/screen" ) @@ -49,6 +49,14 @@ func (s *Service) applyConfig(configData map[string]any) { } } +func (s *Service) requireWindow(name string, operation string) (PlatformWindow, error) { + platformWindow, ok := s.manager.Get(name) + if !ok { + return nil, coreerr.E(operation, "window not found: "+name, nil) + } + return platformWindow, nil +} + func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { return nil } @@ -78,13 +86,13 @@ func (s *Service) queryWindowList() []WindowInfo { names := s.manager.List() result := make([]WindowInfo, 0, len(names)) for _, name := range names { - if pw, ok := s.manager.Get(name); ok { - x, y := pw.Position() - w, h := pw.Size() + if platformWindow, ok := s.manager.Get(name); ok { + x, y := platformWindow.Position() + width, height := platformWindow.Size() result = append(result, WindowInfo{ - Name: name, Title: pw.Title(), X: x, Y: y, Width: w, Height: h, - Maximized: pw.IsMaximised(), - Focused: pw.IsFocused(), + Name: name, Title: platformWindow.Title(), X: x, Y: y, Width: width, Height: height, + Maximized: platformWindow.IsMaximised(), + Focused: platformWindow.IsFocused(), }) } } @@ -92,16 +100,16 @@ func (s *Service) queryWindowList() []WindowInfo { } func (s *Service) queryWindowByName(name string) *WindowInfo { - pw, ok := s.manager.Get(name) + platformWindow, ok := s.manager.Get(name) if !ok { return nil } - x, y := pw.Position() - w, h := pw.Size() + x, y := platformWindow.Position() + width, height := platformWindow.Size() return &WindowInfo{ - Name: name, Title: pw.Title(), X: x, Y: y, Width: w, Height: h, - Maximized: pw.IsMaximised(), - Focused: pw.IsFocused(), + Name: name, Title: platformWindow.Title(), X: x, Y: y, Width: width, Height: height, + Maximized: platformWindow.IsMaximised(), + Focused: platformWindow.IsFocused(), } } @@ -203,55 +211,55 @@ func (s *Service) primaryScreenArea() (int, int, int, int) { } func (s *Service) taskOpenWindow(t TaskOpenWindow) (any, bool, error) { - pw, err := s.manager.Create(t.Window) + platformWindow, err := s.manager.Create(t.Window) if err != nil { return nil, true, err } - x, y := pw.Position() - w, h := pw.Size() - info := WindowInfo{Name: pw.Name(), Title: pw.Title(), X: x, Y: y, Width: w, Height: h} + x, y := platformWindow.Position() + width, height := platformWindow.Size() + info := WindowInfo{Name: platformWindow.Name(), Title: platformWindow.Title(), X: x, Y: y, Width: width, Height: height} // Attach platform event listeners that convert to IPC actions - s.trackWindow(pw) + s.trackWindow(platformWindow) // Broadcast to all listeners - _ = s.Core().ACTION(ActionWindowOpened{Name: pw.Name()}) + _ = s.Core().ACTION(ActionWindowOpened{Name: platformWindow.Name()}) return info, true, nil } // trackWindow attaches platform event listeners that emit IPC actions. -func (s *Service) trackWindow(pw PlatformWindow) { - pw.OnWindowEvent(func(e WindowEvent) { - switch e.Type { +func (s *Service) trackWindow(platformWindow PlatformWindow) { + platformWindow.OnWindowEvent(func(event WindowEvent) { + switch event.Type { case "focus": - _ = s.Core().ACTION(ActionWindowFocused{Name: e.Name}) + _ = s.Core().ACTION(ActionWindowFocused{Name: event.Name}) case "blur": - _ = s.Core().ACTION(ActionWindowBlurred{Name: e.Name}) + _ = s.Core().ACTION(ActionWindowBlurred{Name: event.Name}) case "move": - if data := e.Data; data != nil { + if data := event.Data; data != nil { x, _ := data["x"].(int) y, _ := data["y"].(int) - _ = s.Core().ACTION(ActionWindowMoved{Name: e.Name, X: x, Y: y}) + _ = s.Core().ACTION(ActionWindowMoved{Name: event.Name, X: x, Y: y}) } case "resize": - if data := e.Data; data != nil { - w, _ := data["width"].(int) - if w == 0 { - w, _ = data["w"].(int) + if data := event.Data; data != nil { + width, _ := data["width"].(int) + if width == 0 { + width, _ = data["w"].(int) } - h, _ := data["height"].(int) - if h == 0 { - h, _ = data["h"].(int) + height, _ := data["height"].(int) + if height == 0 { + height, _ = data["h"].(int) } - _ = s.Core().ACTION(ActionWindowResized{Name: e.Name, Width: w, Height: h}) + _ = s.Core().ACTION(ActionWindowResized{Name: event.Name, Width: width, Height: height}) } case "close": - _ = s.Core().ACTION(ActionWindowClosed{Name: e.Name}) + _ = s.Core().ACTION(ActionWindowClosed{Name: event.Name}) } }) - pw.OnFileDrop(func(paths []string, targetID string) { + platformWindow.OnFileDrop(func(paths []string, targetID string) { _ = s.Core().ACTION(ActionFilesDropped{ - Name: pw.Name(), + Name: platformWindow.Name(), Paths: paths, TargetID: targetID, }) @@ -259,121 +267,121 @@ func (s *Service) trackWindow(pw PlatformWindow) { } func (s *Service) taskCloseWindow(name string) error { - pw, ok := s.manager.Get(name) - if !ok { - return fmt.Errorf("window not found: %s", name) + platformWindow, err := s.requireWindow(name, "window.Service.taskCloseWindow") + if err != nil { + return err } // Persist state BEFORE closing (spec requirement) - s.manager.State().CaptureState(pw) - pw.Close() + s.manager.State().CaptureState(platformWindow) + platformWindow.Close() s.manager.Remove(name) _ = s.Core().ACTION(ActionWindowClosed{Name: name}) return nil } func (s *Service) taskSetPosition(name string, x, y int) error { - pw, ok := s.manager.Get(name) - if !ok { - return fmt.Errorf("window not found: %s", name) + platformWindow, err := s.requireWindow(name, "window.Service.taskSetPosition") + if err != nil { + return err } - pw.SetPosition(x, y) + platformWindow.SetPosition(x, y) s.manager.State().UpdatePosition(name, x, y) return nil } 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) + platformWindow, err := s.requireWindow(name, "window.Service.taskSetSize") + if err != nil { + return err } - pw.SetSize(width, height) + platformWindow.SetSize(width, height) s.manager.State().UpdateSize(name, width, height) return nil } func (s *Service) taskMaximise(name string) error { - pw, ok := s.manager.Get(name) - if !ok { - return fmt.Errorf("window not found: %s", name) + platformWindow, err := s.requireWindow(name, "window.Service.taskMaximise") + if err != nil { + return err } - pw.Maximise() + platformWindow.Maximise() s.manager.State().UpdateMaximized(name, true) return nil } func (s *Service) taskMinimise(name string) error { - pw, ok := s.manager.Get(name) - if !ok { - return fmt.Errorf("window not found: %s", name) + platformWindow, err := s.requireWindow(name, "window.Service.taskMinimise") + if err != nil { + return err } - pw.Minimise() + platformWindow.Minimise() return nil } func (s *Service) taskFocus(name string) error { - pw, ok := s.manager.Get(name) - if !ok { - return fmt.Errorf("window not found: %s", name) + platformWindow, err := s.requireWindow(name, "window.Service.taskFocus") + if err != nil { + return err } - pw.Focus() + platformWindow.Focus() return nil } func (s *Service) taskRestore(name string) error { - pw, ok := s.manager.Get(name) - if !ok { - return fmt.Errorf("window not found: %s", name) + platformWindow, err := s.requireWindow(name, "window.Service.taskRestore") + if err != nil { + return err } - pw.Restore() + platformWindow.Restore() s.manager.State().UpdateMaximized(name, false) return nil } func (s *Service) taskSetTitle(name, title string) error { - pw, ok := s.manager.Get(name) - if !ok { - return fmt.Errorf("window not found: %s", name) + platformWindow, err := s.requireWindow(name, "window.Service.taskSetTitle") + if err != nil { + return err } - pw.SetTitle(title) + platformWindow.SetTitle(title) return nil } func (s *Service) taskSetAlwaysOnTop(name string, alwaysOnTop bool) error { - pw, ok := s.manager.Get(name) - if !ok { - return fmt.Errorf("window not found: %s", name) + platformWindow, err := s.requireWindow(name, "window.Service.taskSetAlwaysOnTop") + if err != nil { + return err } - pw.SetAlwaysOnTop(alwaysOnTop) + platformWindow.SetAlwaysOnTop(alwaysOnTop) return nil } func (s *Service) taskSetBackgroundColour(name string, red, green, blue, alpha uint8) error { - pw, ok := s.manager.Get(name) - if !ok { - return fmt.Errorf("window not found: %s", name) + platformWindow, err := s.requireWindow(name, "window.Service.taskSetBackgroundColour") + if err != nil { + return err } - pw.SetBackgroundColour(red, green, blue, alpha) + platformWindow.SetBackgroundColour(red, green, blue, alpha) return nil } func (s *Service) taskSetVisibility(name string, visible bool) error { - pw, ok := s.manager.Get(name) - if !ok { - return fmt.Errorf("window not found: %s", name) + platformWindow, err := s.requireWindow(name, "window.Service.taskSetVisibility") + if err != nil { + return err } - pw.SetVisibility(visible) + platformWindow.SetVisibility(visible) return nil } func (s *Service) taskFullscreen(name string, fullscreen bool) error { - pw, ok := s.manager.Get(name) - if !ok { - return fmt.Errorf("window not found: %s", name) + platformWindow, err := s.requireWindow(name, "window.Service.taskFullscreen") + if err != nil { + return err } if fullscreen { - pw.Fullscreen() + platformWindow.Fullscreen() } else { - pw.UnFullscreen() + platformWindow.UnFullscreen() } return nil } @@ -393,21 +401,21 @@ func (s *Service) taskSaveLayout(name string) error { func (s *Service) taskRestoreLayout(name string) error { layout, ok := s.manager.Layout().GetLayout(name) if !ok { - return fmt.Errorf("layout not found: %s", name) + return coreerr.E("window.Service.taskRestoreLayout", "layout not found: "+name, nil) } for winName, state := range layout.Windows { - pw, found := s.manager.Get(winName) + platformWindow, found := s.manager.Get(winName) if !found { continue } - pw.SetPosition(state.X, state.Y) - pw.SetSize(state.Width, state.Height) + platformWindow.SetPosition(state.X, state.Y) + platformWindow.SetSize(state.Width, state.Height) if state.Maximized { - pw.Maximise() + platformWindow.Maximise() } else { - pw.Restore() + platformWindow.Restore() } - s.manager.State().CaptureState(pw) + s.manager.State().CaptureState(platformWindow) } return nil } @@ -423,7 +431,7 @@ var tileModeMap = map[string]TileMode{ func (s *Service) taskTileWindows(mode string, names []string) error { tm, ok := tileModeMap[mode] if !ok { - return fmt.Errorf("unknown tile mode: %s", mode) + return coreerr.E("window.Service.taskTileWindows", "unknown tile mode: "+mode, nil) } if len(names) == 0 { names = s.manager.List() @@ -451,7 +459,7 @@ var snapPosMap = map[string]SnapPosition{ func (s *Service) taskSnapWindow(name, position string) error { pos, ok := snapPosMap[position] if !ok { - return fmt.Errorf("unknown snap position: %s", position) + return coreerr.E("window.Service.taskSnapWindow", "unknown snap position: "+position, nil) } originX, originY, screenWidth, screenHeight := s.primaryScreenArea() return s.manager.SnapWindow(name, pos, screenWidth, screenHeight, originX, originY) @@ -467,7 +475,7 @@ var workflowLayoutMap = map[string]WorkflowLayout{ func (s *Service) taskApplyWorkflow(workflow string, names []string) error { layout, ok := workflowLayoutMap[workflow] if !ok { - return fmt.Errorf("unknown workflow layout: %s", workflow) + return coreerr.E("window.Service.taskApplyWorkflow", "unknown workflow layout: "+workflow, nil) } if len(names) == 0 { names = s.manager.List() diff --git a/pkg/window/state.go b/pkg/window/state.go index 1b84d07..044a0f2 100644 --- a/pkg/window/state.go +++ b/pkg/window/state.go @@ -5,6 +5,7 @@ import ( "encoding/json" "os" "path/filepath" + "sort" "sync" "time" @@ -208,6 +209,7 @@ func (sm *StateManager) ListStates() []string { for name := range sm.states { names = append(names, name) } + sort.Strings(names) return names } diff --git a/pkg/window/window.go b/pkg/window/window.go index 9950940..31d38cd 100644 --- a/pkg/window/window.go +++ b/pkg/window/window.go @@ -1,7 +1,10 @@ // pkg/window/window.go package window -import "sync" +import ( + "sort" + "sync" +) // Window is CoreGUI's own window descriptor — NOT a Wails type alias. type Window struct { @@ -142,6 +145,7 @@ func (m *Manager) List() []string { for name := range m.windows { names = append(names, name) } + sort.Strings(names) return names }