From b559562dd9fa3ec50b136eca8977af5be6b365b9 Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 17 Mar 2026 09:05:35 +0000 Subject: [PATCH 01/12] fix(dx): use coreerr.E() and go-io, update CLAUDE.md, add tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace 90+ fmt.Errorf calls with coreerr.E() from go-log across display, window, systray, keybinding, contextmenu, and mcp packages - Replace os.ReadFile/WriteFile/MkdirAll with coreio.Local in window/layout.go and window/state.go - Update CLAUDE.md: fix key files table for new package structure, document error handling and file I/O conventions, add missing deps - Add 37 tests for window package (task handlers, persistence, tiling modes, snap positions, workflow layouts) - Window coverage: 47.1% → 69.8% Co-Authored-By: Virgil --- CLAUDE.md | 55 +++--- go.mod | 4 +- pkg/contextmenu/service.go | 6 +- pkg/display/display.go | 49 ++--- pkg/keybinding/service.go | 8 +- pkg/mcp/tools_clipboard.go | 10 +- pkg/mcp/tools_contextmenu.go | 18 +- pkg/mcp/tools_dialog.go | 12 +- pkg/mcp/tools_environment.go | 6 +- pkg/mcp/tools_layout.go | 6 +- pkg/mcp/tools_notification.go | 6 +- pkg/mcp/tools_screen.go | 12 +- pkg/mcp/tools_tray.go | 4 +- pkg/mcp/tools_webview.go | 16 +- pkg/mcp/tools_window.go | 10 +- pkg/systray/menu.go | 4 +- pkg/systray/tray.go | 15 +- pkg/window/layout.go | 14 +- pkg/window/mock_test.go | 8 +- pkg/window/persistence_test.go | 318 +++++++++++++++++++++++++++++++++ pkg/window/service.go | 28 +-- pkg/window/service_test.go | 235 ++++++++++++++++++++++++ pkg/window/state.go | 10 +- pkg/window/tiling.go | 12 +- pkg/window/window.go | 5 +- pkg/window/window_test.go | 312 +++++++++++++++++++------------- 26 files changed, 908 insertions(+), 275 deletions(-) create mode 100644 pkg/window/persistence_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 594d4c0a..402eb18a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,38 +38,43 @@ The display `Service` registers with `forge.lthn.ai/core/go`'s service container All Wails application APIs are abstracted behind interfaces in `interfaces.go` (`App`, `WindowManager`, `MenuManager`, `DialogManager`, etc.). The `wailsApp` adapter wraps the real Wails app. Tests inject a `mockApp` instead — see `mocks_test.go` and the `newServiceWithMockApp(t)` helper. -### Key files in pkg/display/ +### Package structure (pkg/) -| File | Responsibility | -|------|---------------| -| `display.go` | Service struct, lifecycle (`Startup`), window CRUD, screen queries, tiling/snapping/layout, workflow presets | -| `window.go` | `WindowOption` functional options pattern, `Window` type alias for `application.WebviewWindowOptions` | -| `window_state.go` | `WindowStateManager` — persists window position/size across restarts | -| `layout.go` | `LayoutManager` — save/restore named window arrangements | -| `events.go` | `WSEventManager` — WebSocket pub/sub for window/theme/screen events | -| `interfaces.go` | Abstract interfaces + Wails adapter implementations | -| `actions.go` | `ActionOpenWindow` IPC message type | -| `menu.go` | Application menu construction | -| `tray.go` | System tray setup | -| `dialog.go` | File/directory dialogs | -| `clipboard.go` | Clipboard read/write | -| `notification.go` | System notifications | -| `theme.go` | Dark/light mode detection | -| `mocks_test.go` | Mock implementations of all interfaces for testing | +| Package | Responsibility | +|---------|---------------| +| `display` | Orchestrator service — bridges sub-service IPC to WebSocket events, menu/tray setup, config persistence | +| `window` | Window lifecycle, `Manager`, `StateManager` (position persistence), `LayoutManager` (named arrangements), tiling/snapping | +| `menu` | Application menu construction via platform abstraction | +| `systray` | System tray icon, tooltip, menu via platform abstraction | +| `dialog` | File open/save, message, confirm, and prompt dialogs | +| `clipboard` | Clipboard read/write/clear | +| `notification` | System notifications with permission management | +| `screen` | Screen/monitor queries (list, primary, at-point, work areas) | +| `environment` | Theme detection (dark/light) and OS environment info | +| `keybinding` | Global keyboard shortcut registration | +| `contextmenu` | Named context menu registration and lifecycle | +| `browser` | Open URLs and files in the default browser | +| `dock` | macOS dock icon visibility and badge | +| `lifecycle` | Application lifecycle events (start, terminate, suspend, resume) | +| `webview` | Webview automation (eval, click, type, screenshot, DOM queries) | +| `mcp` | MCP tool subsystem — exposes all services as Model Context Protocol tools | ### Patterns used throughout -- **Functional options**: `WindowOption` functions (`WindowName()`, `WindowTitle()`, `WindowWidth()`, etc.) configure `application.WebviewWindowOptions` -- **Type alias**: `Window = application.WebviewWindowOptions` — direct alias, not a wrapper +- **Platform abstraction**: Each sub-service defines a `Platform` interface and `PlatformWindow`/`PlatformTray`/etc. types; tests inject mocks +- **Functional options**: `WindowOption` functions (`WithName()`, `WithTitle()`, `WithSize()`, etc.) configure `window.Window` descriptors +- **IPC message bus**: Sub-services communicate via `core.QUERY`, `core.PERFORM`, and `core.ACTION` — display orchestrates and bridges to WebSocket events - **Event broadcasting**: `WSEventManager` uses gorilla/websocket with a buffered channel (`eventBuffer`) and per-client subscription filtering (supports `"*"` wildcard) -- **Window lookup by name**: Most Service methods iterate `s.app.Window().GetAll()` and type-assert to `*application.WebviewWindow`, then match by `Name()` +- **Error handling**: All errors use `coreerr.E(op, msg, err)` from `forge.lthn.ai/core/go-log` (aliased as `coreerr`), never `fmt.Errorf` +- **File I/O**: Use `forge.lthn.ai/core/go-io` (`coreio.Local`) for filesystem operations, never `os.ReadFile`/`os.WriteFile` ## Testing - Framework: `testify` (assert + require) -- Pattern: `newServiceWithMockApp(t)` creates a `Service` with mock Wails app — no real window system needed -- `newTestCore(t)` creates a real `core.Core` instance for integration-style tests -- Some tests use `defer func() { recover() }()` to handle nil panics from mock methods that return nil pointers (e.g., `Dialog().Info()`) +- Each sub-package has its own `*_test.go` with mock platform implementations +- `pkg/window`: `NewManagerWithDir` / `NewStateManagerWithDir` / `NewLayoutManagerWithDir` accept custom config dirs for isolated tests +- `pkg/display`: `newTestCore(t)` creates a real `core.Core` instance for integration-style tests +- Sub-services use `mock_platform.go` or `mock_test.go` for platform mocks ## CI/CD @@ -82,9 +87,13 @@ Both use reusable workflows from `core/go-devops`. ## Dependencies - `forge.lthn.ai/core/go` — Core framework with service container and DI +- `forge.lthn.ai/core/go-log` — Structured errors (`coreerr.E()`) +- `forge.lthn.ai/core/go-io` — Filesystem abstraction (`coreio.Local`) +- `forge.lthn.ai/core/config` — Configuration file management - `github.com/wailsapp/wails/v3` — Desktop app framework (alpha.74) - `github.com/gorilla/websocket` — WebSocket for real-time events - `github.com/stretchr/testify` — Test assertions +- `github.com/modelcontextprotocol/go-sdk` — MCP tool registration ## Repository migration note diff --git a/go.mod b/go.mod index 653c918c..227fed11 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,8 @@ go 1.26.0 require ( forge.lthn.ai/core/config v0.1.6 forge.lthn.ai/core/go v0.3.1 + forge.lthn.ai/core/go-io v0.1.5 + forge.lthn.ai/core/go-log v0.0.4 forge.lthn.ai/core/go-webview v0.1.5 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.5 // 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/contextmenu/service.go b/pkg/contextmenu/service.go index 973346d2..44ef16ba 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" ) @@ -92,7 +92,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.menus[t.Name] = t.Menu @@ -106,7 +106,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.menus, t.Name) diff --git a/pkg/display/display.go b/pkg/display/display.go index 9b6b77a6..96e031da 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -9,6 +9,7 @@ import ( "forge.lthn.ai/core/config" "forge.lthn.ai/core/go/pkg/core" + coreerr "forge.lthn.ai/core/go-log" "encoding/json" "forge.lthn.ai/core/gui/pkg/browser" @@ -241,7 +242,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", fmt.Sprintf("missing required field %q", key), nil) } return v, nil } @@ -600,7 +601,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 @@ -666,11 +667,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 +682,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 +697,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 +712,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 +727,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 +746,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 +774,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 } @@ -816,7 +817,7 @@ 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") + return nil, coreerr.E("display.CreateWindow", "window name is required", nil) } result, _, err := s.Core().PERFORM(window.TaskOpenWindow{ Opts: []window.WindowOption{ @@ -840,7 +841,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 +858,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 +891,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,7 +916,7 @@ 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 } @@ -924,7 +925,7 @@ func (s *Service) TileWindows(mode window.TileMode, windowNames []string) error 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 } @@ -933,7 +934,7 @@ func (s *Service) SnapWindow(name string, position window.SnapPosition) error { 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 +943,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) } diff --git a/pkg/keybinding/service.go b/pkg/keybinding/service.go index 048c259d..8461292c 100644 --- a/pkg/keybinding/service.go +++ b/pkg/keybinding/service.go @@ -3,8 +3,8 @@ package keybinding import ( "context" - "fmt" + coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/go/pkg/core" ) @@ -75,7 +75,7 @@ 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{ @@ -87,12 +87,12 @@ 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) + 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) diff --git a/pkg/mcp/tools_clipboard.go b/pkg/mcp/tools_clipboard.go index 827586a1..629d46ae 100644 --- a/pkg/mcp/tools_clipboard.go +++ b/pkg/mcp/tools_clipboard.go @@ -3,9 +3,9 @@ package mcp import ( "context" - "fmt" "forge.lthn.ai/core/gui/pkg/clipboard" + coreerr "forge.lthn.ai/core/go-log" "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 74e9f8b5..12eed8fb 100644 --- a/pkg/mcp/tools_contextmenu.go +++ b/pkg/mcp/tools_contextmenu.go @@ -4,9 +4,9 @@ package mcp import ( "context" "encoding/json" - "fmt" "forge.lthn.ai/core/gui/pkg/contextmenu" + coreerr "forge.lthn.ai/core/go-log" "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 06bdf668..bfc6015d 100644 --- a/pkg/mcp/tools_dialog.go +++ b/pkg/mcp/tools_dialog.go @@ -3,9 +3,9 @@ package mcp import ( "context" - "fmt" "forge.lthn.ai/core/gui/pkg/dialog" + coreerr "forge.lthn.ai/core/go-log" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -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 } @@ -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 } @@ -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 } @@ -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 } @@ -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 87eb0df4..8d8ad495 100644 --- a/pkg/mcp/tools_environment.go +++ b/pkg/mcp/tools_environment.go @@ -3,9 +3,9 @@ package mcp import ( "context" - "fmt" "forge.lthn.ai/core/gui/pkg/environment" + coreerr "forge.lthn.ai/core/go-log" "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 24ec8fa2..18066d33 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 259e59fd..06290542 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", 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", nil) } return nil, NotificationPermissionCheckOutput{Granted: status.Granted}, nil } diff --git a/pkg/mcp/tools_screen.go b/pkg/mcp/tools_screen.go index 8a276b9e..a89e879d 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 0cbad22e..d5efb457 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 b598a4b8..923fe365 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 e5ac73f2..16484d94 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", 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", 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", nil) } for _, w := range windows { if w.Focused { @@ -110,7 +110,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", nil) } return nil, WindowCreateOutput{Window: info}, nil } diff --git a/pkg/systray/menu.go b/pkg/systray/menu.go index 3032a6d1..8594f335 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.SetMenu", "tray not initialised", nil) } menu := m.buildMenu(items) m.tray.SetMenu(menu) diff --git a/pkg/systray/tray.go b/pkg/systray/tray.go index 05ffcdfd..8d2e1087 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/window/layout.go b/pkg/window/layout.go index 545a99f9..7021a724 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/mock_test.go b/pkg/window/mock_test.go index 72d54ca8..8bb08a9a 100644 --- a/pkg/window/mock_test.go +++ b/pkg/window/mock_test.go @@ -33,6 +33,8 @@ type mockWindow struct { maximised, focused bool visible, alwaysOnTop bool closed bool + minimised bool + fullscreened bool eventHandlers []func(WindowEvent) fileDropHandlers []func(paths []string, targetID string) } @@ -51,13 +53,13 @@ func (w *mockWindow) SetVisibility(visible bool) { w.visible = visi 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) 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() {} -func (w *mockWindow) UnFullscreen() {} +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/persistence_test.go b/pkg/window/persistence_test.go new file mode 100644 index 00000000..6a4ee74d --- /dev/null +++ b/pkg/window/persistence_test.go @@ -0,0 +1,318 @@ +// pkg/window/persistence_test.go +package window + +import ( + "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) +} + +// --- 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/service.go b/pkg/window/service.go index 040ab95a..8260e453 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" ) @@ -207,7 +207,7 @@ 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) + return coreerr.E("window.taskClose", "window not found: "+name, nil) } // Persist state BEFORE closing (spec requirement) s.manager.State().CaptureState(pw) @@ -220,7 +220,7 @@ func (s *Service) taskCloseWindow(name string) error { 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) + return coreerr.E("window.taskSetPosition", "window not found: "+name, nil) } pw.SetPosition(x, y) s.manager.State().UpdatePosition(name, x, y) @@ -230,7 +230,7 @@ func (s *Service) taskSetPosition(name string, x, y int) error { func (s *Service) taskSetSize(name string, w, h int) error { pw, ok := s.manager.Get(name) if !ok { - return fmt.Errorf("window not found: %s", name) + return coreerr.E("window.taskSetSize", "window not found: "+name, nil) } pw.SetSize(w, h) s.manager.State().UpdateSize(name, w, h) @@ -240,7 +240,7 @@ func (s *Service) taskSetSize(name string, w, h int) error { func (s *Service) taskMaximise(name string) error { pw, ok := s.manager.Get(name) if !ok { - return fmt.Errorf("window not found: %s", name) + return coreerr.E("window.taskMaximise", "window not found: "+name, nil) } pw.Maximise() s.manager.State().UpdateMaximized(name, true) @@ -250,7 +250,7 @@ func (s *Service) taskMaximise(name string) error { func (s *Service) taskMinimise(name string) error { pw, ok := s.manager.Get(name) if !ok { - return fmt.Errorf("window not found: %s", name) + return coreerr.E("window.taskMinimise", "window not found: "+name, nil) } pw.Minimise() return nil @@ -259,7 +259,7 @@ func (s *Service) taskMinimise(name string) error { func (s *Service) taskFocus(name string) error { pw, ok := s.manager.Get(name) if !ok { - return fmt.Errorf("window not found: %s", name) + return coreerr.E("window.taskFocus", "window not found: "+name, nil) } pw.Focus() return nil @@ -268,7 +268,7 @@ func (s *Service) taskFocus(name string) error { func (s *Service) taskRestore(name string) error { pw, ok := s.manager.Get(name) if !ok { - return fmt.Errorf("window not found: %s", name) + return coreerr.E("window.taskRestore", "window not found: "+name, nil) } pw.Restore() s.manager.State().UpdateMaximized(name, false) @@ -278,7 +278,7 @@ func (s *Service) taskRestore(name string) error { func (s *Service) taskSetTitle(name, title string) error { pw, ok := s.manager.Get(name) if !ok { - return fmt.Errorf("window not found: %s", name) + return coreerr.E("window.taskSetTitle", "window not found: "+name, nil) } pw.SetTitle(title) return nil @@ -287,7 +287,7 @@ func (s *Service) taskSetTitle(name, title string) error { 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) + return coreerr.E("window.taskSetVisibility", "window not found: "+name, nil) } pw.SetVisibility(visible) return nil @@ -296,7 +296,7 @@ func (s *Service) taskSetVisibility(name string, visible bool) error { 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) + return coreerr.E("window.taskFullscreen", "window not found: "+name, nil) } if fullscreen { pw.Fullscreen() @@ -321,7 +321,7 @@ 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.taskRestoreLayout", "layout not found: "+name, nil) } for winName, state := range layout.Windows { pw, found := s.manager.Get(winName) @@ -348,7 +348,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.taskTileWindows", "unknown tile mode: "+mode, nil) } if len(names) == 0 { names = s.manager.List() @@ -368,7 +368,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.taskSnapWindow", "unknown snap position: "+position, nil) } return s.manager.SnapWindow(name, pos, 1920, 1080) } diff --git a/pkg/window/service_test.go b/pkg/window/service_test.go index 1911044f..4df1108a 100644 --- a/pkg/window/service_test.go +++ b/pkg/window/service_test.go @@ -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{Opts: []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{Opts: []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{Opts: []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{Opts: []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{Opts: []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{Opts: []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{Opts: []WindowOption{WithName("editor"), WithSize(960, 1080), WithPosition(0, 0)}}) + _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []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{Opts: []WindowOption{WithName("editor"), WithSize(800, 600), WithPosition(0, 0)}}) + _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []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 3523cfe4..2ff9d870 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. @@ -62,13 +64,13 @@ func (sm *StateManager) load() { if sm.configDir == "" { return } - data, err := os.ReadFile(sm.filePath()) + 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() { @@ -81,8 +83,8 @@ func (sm *StateManager) save() { if err != nil { return } - _ = os.MkdirAll(sm.configDir, 0o755) - _ = os.WriteFile(sm.filePath(), data, 0o644) + _ = coreio.Local.EnsureDir(sm.configDir) + _ = coreio.Local.Write(sm.filePath(), string(data)) } func (sm *StateManager) scheduleSave() { diff --git a/pkg/window/tiling.go b/pkg/window/tiling.go index 40669fe2..e6ee4300 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/window.go b/pkg/window/window.go index 3692fe88..937f1fd3 100644 --- a/pkg/window/window.go +++ b/pkg/window/window.go @@ -2,8 +2,9 @@ package window import ( - "fmt" "sync" + + coreerr "forge.lthn.ai/core/go-log" ) // Window is CoreGUI's own window descriptor — NOT a Wails type alias. @@ -70,7 +71,7 @@ func NewManagerWithDir(platform Platform, configDir string) *Manager { func (m *Manager) Open(opts ...WindowOption) (PlatformWindow, error) { w, err := ApplyOptions(opts...) if err != nil { - return nil, fmt.Errorf("window.Manager.Open: %w", err) + return nil, coreerr.E("window.Manager.Open", "failed to apply options", err) } return m.Create(w) } diff --git a/pkg/window/window_test.go b/pkg/window/window_test.go index 44d1f095..4ed75e5b 100644 --- a/pkg/window/window_test.go +++ b/pkg/window/window_test.go @@ -148,131 +148,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 +203,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) +} From bd58099c170480106b174cd2de20f6dba6b1f9c3 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 30 Mar 2026 23:56:08 +0100 Subject: [PATCH 02/12] docs: add AX design principles RFC for agent dispatch Co-Authored-By: Virgil --- docs/RFC-CORE-008-AGENT-EXPERIENCE.md | 440 ++++++++++++++++++++++++++ 1 file changed, 440 insertions(+) create mode 100644 docs/RFC-CORE-008-AGENT-EXPERIENCE.md diff --git a/docs/RFC-CORE-008-AGENT-EXPERIENCE.md b/docs/RFC-CORE-008-AGENT-EXPERIENCE.md new file mode 100644 index 00000000..37635218 --- /dev/null +++ b/docs/RFC-CORE-008-AGENT-EXPERIENCE.md @@ -0,0 +1,440 @@ +# RFC-025: Agent Experience (AX) Design Principles + +- **Status:** Draft +- **Authors:** Snider, Cladius +- **Date:** 2026-03-19 +- **Applies to:** All Core ecosystem packages (CoreGO, CorePHP, CoreTS, core-agent) + +## Abstract + +Agent Experience (AX) is a design paradigm for software systems where the primary code consumer is an AI agent, not a human developer. AX sits alongside User Experience (UX) and Developer Experience (DX) as the third era of interface design. + +This RFC establishes AX as a formal design principle for the Core ecosystem and defines the conventions that follow from it. + +## Motivation + +As of early 2026, AI agents write, review, and maintain the majority of code in the Core ecosystem. The original author has not manually edited code (outside of Core struct design) since October 2025. Code is processed semantically — agents reason about intent, not characters. + +Design patterns inherited from the human-developer era optimise for the wrong consumer: + +- **Short names** save keystrokes but increase semantic ambiguity +- **Functional option chains** are fluent for humans but opaque for agents tracing configuration +- **Error-at-every-call-site** produces 50% boilerplate that obscures intent +- **Generic type parameters** force agents to carry type context that the runtime already has +- **Panic-hiding conventions** (`Must*`) create implicit control flow that agents must special-case + +AX acknowledges this shift and provides principles for designing code, APIs, file structures, and conventions that serve AI agents as first-class consumers. + +## The Three Eras + +| Era | Primary Consumer | Optimises For | Key Metric | +|-----|-----------------|---------------|------------| +| UX | End users | Discoverability, forgiveness, visual clarity | Task completion time | +| DX | Developers | Typing speed, IDE support, convention familiarity | Time to first commit | +| AX | AI agents | Predictability, composability, semantic navigation | Correct-on-first-pass rate | + +AX does not replace UX or DX. End users still need good UX. Developers still need good DX. But when the primary code author and maintainer is an AI agent, the codebase should be designed for that consumer first. + +## Principles + +### 1. Predictable Names Over Short Names + +Names are tokens that agents pattern-match across languages and contexts. Abbreviations introduce mapping overhead. + +``` +Config not Cfg +Service not Srv +Embed not Emb +Error not Err (as a subsystem name; err for local variables is fine) +Options not Opts +``` + +**Rule:** If a name would require a comment to explain, it is too short. + +**Exception:** Industry-standard abbreviations that are universally understood (`HTTP`, `URL`, `ID`, `IPC`, `I18n`) are acceptable. The test: would an agent trained on any mainstream language recognise it without context? + +### 2. Comments as Usage Examples + +The function signature tells WHAT. The comment shows HOW with real values. + +```go +// Detect the project type from files present +setup.Detect("/path/to/project") + +// Set up a workspace with auto-detected template +setup.Run(setup.Options{Path: ".", Template: "auto"}) + +// Scaffold a PHP module workspace +setup.Run(setup.Options{Path: "./my-module", Template: "php"}) +``` + +**Rule:** If a comment restates what the type signature already says, delete it. If a comment shows a concrete usage with realistic values, keep it. + +**Rationale:** Agents learn from examples more effectively than from descriptions. A comment like "Run executes the setup process" adds zero information. A comment like `setup.Run(setup.Options{Path: ".", Template: "auto"})` teaches an agent exactly how to call the function. + +### 3. Path Is Documentation + +File and directory paths should be self-describing. An agent navigating the filesystem should understand what it is looking at without reading a README. + +``` +flow/deploy/to/homelab.yaml — deploy TO the homelab +flow/deploy/from/github.yaml — deploy FROM GitHub +flow/code/review.yaml — code review flow +template/file/go/struct.go.tmpl — Go struct file template +template/dir/workspace/php/ — PHP workspace scaffold +``` + +**Rule:** If an agent needs to read a file to understand what a directory contains, the directory naming has failed. + +**Corollary:** The unified path convention (folder structure = HTTP route = CLI command = test path) is AX-native. One path, every surface. + +### 4. Templates Over Freeform + +When an agent generates code from a template, the output is constrained to known-good shapes. When an agent writes freeform, the output varies. + +```go +// Template-driven — consistent output +lib.RenderFile("php/action", data) +lib.ExtractDir("php", targetDir, data) + +// Freeform — variance in output +"write a PHP action class that..." +``` + +**Rule:** For any code pattern that recurs, provide a template. Templates are guardrails for agents. + +**Scope:** Templates apply to file generation, workspace scaffolding, config generation, and commit messages. They do NOT apply to novel logic — agents should write business logic freeform with the domain knowledge available. + +### 5. Declarative Over Imperative + +Agents reason better about declarations of intent than sequences of operations. + +```yaml +# Declarative — agent sees what should happen +steps: + - name: build + flow: tools/docker-build + with: + context: "{{ .app_dir }}" + image_name: "{{ .image_name }}" + + - name: deploy + flow: deploy/with/docker + with: + host: "{{ .host }}" +``` + +```go +// Imperative — agent must trace execution +cmd := exec.Command("docker", "build", "--platform", "linux/amd64", "-t", imageName, ".") +cmd.Dir = appDir +if err := cmd.Run(); err != nil { + return fmt.Errorf("docker build: %w", err) +} +``` + +**Rule:** Orchestration, configuration, and pipeline logic should be declarative (YAML/JSON). Implementation logic should be imperative (Go/PHP/TS). The boundary is: if an agent needs to compose or modify the logic, make it declarative. + +### 6. Universal Types (Core Primitives) + +Every component in the ecosystem accepts and returns the same primitive types. An agent processing any level of the tree sees identical shapes. + +```go +// Universal contract +setup.Run(core.Options{Path: ".", Template: "auto"}) +brain.New(core.Options{Name: "openbrain"}) +deploy.Run(core.Options{Flow: "deploy/to/homelab"}) + +// Fractal — Core itself is a Service +core.New(core.Options{ + Services: []core.Service{ + process.New(core.Options{Name: "process"}), + brain.New(core.Options{Name: "brain"}), + }, +}) +``` + +**Core primitive types:** + +| Type | Purpose | +|------|---------| +| `core.Options` | Input configuration (what you want) | +| `core.Config` | Runtime settings (what is active) | +| `core.Data` | Embedded or stored content | +| `core.Service` | A managed component with lifecycle | +| `core.Result[T]` | Return value with OK/fail state | + +**What this replaces:** + +| Go Convention | Core AX | Why | +|--------------|---------|-----| +| `func With*(v) Option` | `core.Options{Field: v}` | Struct literal is parseable; option chain requires tracing | +| `func Must*(v) T` | `core.Result[T]` | No hidden panics; errors flow through Core | +| `func *For[T](c) T` | `c.Service("name")` | String lookup is greppable; generics require type context | +| `val, err :=` everywhere | Single return via `core.Result` | Intent not obscured by error handling | +| `_ = err` | Never needed | Core handles all errors internally | + +### 7. Directory as Semantics + +The directory structure tells an agent the intent before it reads a word. Top-level directories are semantic categories, not organisational bins. + +``` +plans/ +├── code/ # Pure primitives — read for WHAT exists +├── project/ # Products — read for WHAT we're building and WHY +└── rfc/ # Contracts — read for constraints and rules +``` + +**Rule:** An agent should know what kind of document it's reading from the path alone. `code/core/go/io/RFC.md` = a lib primitive spec. `project/ofm/RFC.md` = a product spec that cross-references code/. `rfc/snider/borg/RFC-BORG-006-SMSG-FORMAT.md` = an immutable contract for the Borg SMSG protocol. + +**Corollary:** The three-way split (code/project/rfc) extends principle 3 (Path Is Documentation) from files to entire subtrees. The path IS the metadata. + +### 8. Lib Never Imports Consumer + +Dependency flows one direction. Libraries define primitives. Consumers compose from them. A new feature in a consumer can never break a library. + +``` +code/core/go/* → lib tier (stable foundation) +code/core/agent/ → consumer tier (composes from go/*) +code/core/cli/ → consumer tier (composes from go/*) +code/core/gui/ → consumer tier (composes from go/*) +``` + +**Rule:** If package A is in `go/` and package B is in the consumer tier, B may import A but A must never import B. The repo naming convention enforces this: `go-{name}` = lib, bare `{name}` = consumer. + +**Why this matters for agents:** When an agent is dispatched to implement a feature in `core/agent`, it can freely import from `go-io`, `go-scm`, `go-process`. But if an agent is dispatched to `go-io`, it knows its changes are foundational — every consumer depends on it, so the contract must not break. + +### 9. Issues Are N+(rounds) Deep + +Problems in code and specs are layered. Surface issues mask deeper issues. Fixing the surface reveals the next layer. This is not a failure mode — it is the discovery process. + +``` +Pass 1: Find 16 issues (surface — naming, imports, obvious errors) +Pass 2: Find 11 issues (structural — contradictions, missing types) +Pass 3: Find 5 issues (architectural — signature mismatches, registration gaps) +Pass 4: Find 4 issues (contract — cross-spec API mismatches) +Pass 5: Find 2 issues (mechanical — path format, nil safety) +Pass N: Findings are trivial → spec/code is complete +``` + +**Rule:** Iteration is required, not a failure. Each pass sees what the previous pass could not, because the context changed. An agent dispatched with the same task on the same repo will find different things each time — this is correct behaviour. + +**Corollary:** The cheapest model should do the most passes (surface work). The frontier model should arrive last, when only deep issues remain. Tiered iteration: grunt model grinds → mid model pre-warms → frontier model polishes. + +**Anti-pattern:** One-shot generation expecting valid output. No model, no human, produces correct-on-first-pass for non-trivial work. Expecting it wastes the first pass on surface issues that a cheaper pass would have caught. + +### 10. CLI Tests as Artifact Validation + +Unit tests verify the code. CLI tests verify the binary. The directory structure IS the command structure — path maps to command, Taskfile runs the test. + +``` +tests/cli/ +├── core/ +│ └── lint/ +│ ├── Taskfile.yaml ← test `core-lint` (root) +│ ├── run/ +│ │ ├── Taskfile.yaml ← test `core-lint run` +│ │ └── fixtures/ +│ ├── go/ +│ │ ├── Taskfile.yaml ← test `core-lint go` +│ │ └── fixtures/ +│ └── security/ +│ ├── Taskfile.yaml ← test `core-lint security` +│ └── fixtures/ +``` + +**Rule:** Every CLI command has a matching `tests/cli/{path}/Taskfile.yaml`. The Taskfile runs the compiled binary against fixtures with known inputs and validates the output. If the CLI test passes, the underlying actions work — because CLI commands call actions, MCP tools call actions, API endpoints call actions. Test the CLI, trust the rest. + +**Pattern:** + +```yaml +# tests/cli/core/lint/go/Taskfile.yaml +version: '3' +tasks: + test: + cmds: + - core-lint go --output json fixtures/ > /tmp/result.json + - jq -e '.findings | length > 0' /tmp/result.json + - jq -e '.summary.passed == false' /tmp/result.json +``` + +**Why this matters for agents:** An agent can validate its own work by running `task test` in the matching `tests/cli/` directory. No test framework, no mocking, no setup — just the binary, fixtures, and `jq` assertions. The agent builds the binary, runs the test, sees the result. If it fails, the agent can read the fixture, read the output, and fix the code. + +**Corollary:** Fixtures are planted bugs. Each fixture file has a known issue that the linter must find. If the linter doesn't find it, the test fails. Fixtures are the spec for what the tool must detect — they ARE the test cases, not descriptions of test cases. + +## Applying AX to Existing Patterns + +### File Structure + +``` +# AX-native: path describes content +core/agent/ +├── go/ # Go source +├── php/ # PHP source +├── ui/ # Frontend source +├── claude/ # Claude Code plugin +└── codex/ # Codex plugin + +# Not AX: generic names requiring README +src/ +├── lib/ +├── utils/ +└── helpers/ +``` + +### Error Handling + +```go +// AX-native: errors are infrastructure, not application logic +svc := c.Service("brain") +cfg := c.Config().Get("database.host") +// Errors logged by Core. Code reads like a spec. + +// Not AX: errors dominate the code +svc, err := c.ServiceFor[brain.Service]() +if err != nil { + return fmt.Errorf("get brain service: %w", err) +} +cfg, err := c.Config().Get("database.host") +if err != nil { + _ = err // silenced because "it'll be fine" +} +``` + +### API Design + +```go +// AX-native: one shape, every surface +core.New(core.Options{ + Name: "my-app", + Services: []core.Service{...}, + Config: core.Config{...}, +}) + +// Not AX: multiple patterns for the same thing +core.New( + core.WithName("my-app"), + core.WithService(factory1), + core.WithService(factory2), + core.WithConfig(cfg), +) +``` + +## The Plans Convention — AX Development Lifecycle + +The `plans/` directory structure encodes a development methodology designed for how generative AI actually works: iterative refinement across structured phases, not one-shot generation. + +### The Three-Way Split + +``` +plans/ +├── project/ # 1. WHAT and WHY — start here +├── rfc/ # 2. CONSTRAINTS — immutable contracts +└── code/ # 3. HOW — implementation specs +``` + +Each directory is a phase. Work flows from project → rfc → code. Each transition forces a refinement pass — you cannot write a code spec without discovering gaps in the project spec, and you cannot write an RFC without discovering assumptions in both. + +**Three places for data that can't be written simultaneously = three guaranteed iterations of "actually, this needs changing."** Refinement is baked into the structure, not bolted on as a review step. + +### Phase 1: Project (Vision) + +Start with `project/`. No code exists yet. Define: +- What the product IS and who it serves +- What existing primitives it consumes (cross-ref to `code/`) +- What constraints it operates under (cross-ref to `rfc/`) + +This is where creativity lives. Map features to building blocks. Connect systems. The project spec is integrative — it references everything else. + +### Phase 2: RFC (Contracts) + +Extract the immutable rules into `rfc/`. These are constraints that don't change with implementation: +- Wire formats, protocols, hash algorithms +- Security properties that must hold +- Compatibility guarantees + +RFCs are numbered per component (`RFC-BORG-006-SMSG-FORMAT.md`) and never modified after acceptance. If the contract changes, write a new RFC. + +### Phase 3: Code (Implementation Specs) + +Define the implementation in `code/`. Each component gets an RFC.md that an agent can implement from: +- Struct definitions (the DTOs — see principle 6) +- Method signatures and behaviour +- Error conditions and edge cases +- Cross-references to other code/ specs + +The code spec IS the product. Write the spec → dispatch to an agent → review output → iterate. + +### Pre-Launch: Alignment Protocol + +Before dispatching for implementation, verify spec-model alignment: + +``` +1. REVIEW — The implementation model (Codex/Jules) reads the spec + and reports missing elements. This surfaces the delta between + the model's training and the spec's assumptions. + + "I need X, Y, Z to implement this" is the model saying + "I hear you but I'm missing context" — without asking. + +2. ADJUST — Update the spec to close the gaps. Add examples, + clarify ambiguities, provide the context the model needs. + This is shared alignment, not compromise. + +3. VERIFY — A different model (or sub-agent) reviews the adjusted + spec without the planner's bias. Fresh eyes on the contract. + "Does this make sense to someone who wasn't in the room?" + +4. READY — When the review findings are trivial or deployment- + related (not architectural), the spec is ready to dispatch. +``` + +### Implementation: Iterative Dispatch + +Same prompt, multiple runs. Each pass sees deeper because the context evolved: + +``` +Round 1: Build features (the obvious gaps) +Round 2: Write tests (verify what was built) +Round 3: Harden security (what can go wrong?) +Round 4: Next RFC section (what's still missing?) +Round N: Findings are trivial → implementation is complete +``` + +Re-running is not failure. It is the process. Each pass changes the codebase, which changes what the next pass can see. The iteration IS the refinement. + +### Post-Implementation: Auto-Documentation + +The QA/verify chain produces artefacts that feed forward: +- Test results document the contract (what works, what doesn't) +- Coverage reports surface untested paths +- Diff summaries prep the changelog for the next release +- Doc site updates from the spec (the spec IS the documentation) + +The output of one cycle is the input to the next. The plans repo stays current because the specs drive the code, not the other way round. + +## Compatibility + +AX conventions are valid, idiomatic Go/PHP/TS. They do not require language extensions, code generation, or non-standard tooling. An AX-designed codebase compiles, tests, and deploys with standard toolchains. + +The conventions diverge from community patterns (functional options, Must/For, etc.) but do not violate language specifications. This is a style choice, not a fork. + +## Adoption + +AX applies to all new code in the Core ecosystem. Existing code migrates incrementally as it is touched — no big-bang rewrite. + +Priority order: +1. **Public APIs** (package-level functions, struct constructors) +2. **File structure** (path naming, template locations) +3. **Internal fields** (struct field names, local variables) + +## References + +- dAppServer unified path convention (2024) +- CoreGO DTO pattern refactor (2026-03-18) +- Core primitives design (2026-03-19) +- Go Proverbs, Rob Pike (2015) — AX provides an updated lens + +## Changelog + +- 2026-03-19: Initial draft From da22bedbc653c314cd114f20181fb93f9eb97497 Mon Sep 17 00:00:00 2001 From: Virgil Date: Tue, 31 Mar 2026 05:13:43 +0000 Subject: [PATCH 03/12] refactor(ax): align public APIs with AX principles --- 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 | 77 ++++++++++++++++---------------- pkg/display/display_test.go | 8 ++-- pkg/dock/register.go | 3 -- pkg/dock/service.go | 10 ----- pkg/keybinding/messages.go | 17 +------ pkg/keybinding/register.go | 9 ++-- pkg/keybinding/service.go | 25 ++++------- pkg/keybinding/service_test.go | 2 +- pkg/lifecycle/register.go | 3 -- pkg/lifecycle/service.go | 12 ----- pkg/mcp/tools_dialog.go | 12 ++--- pkg/mcp/tools_notification.go | 2 +- 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 | 31 ++++++------- pkg/notification/service_test.go | 4 +- pkg/systray/messages.go | 15 +------ pkg/systray/register.go | 1 - pkg/systray/service.go | 17 +++---- pkg/webview/service.go | 38 ++++++++-------- pkg/window/messages.go | 48 +++----------------- pkg/window/mock_platform.go | 54 +++++++++++----------- pkg/window/mock_test.go | 55 ++++++++++++----------- pkg/window/options.go | 8 ++-- pkg/window/platform.go | 28 ++++++------ pkg/window/register.go | 2 - pkg/window/service.go | 42 +++++++---------- pkg/window/service_test.go | 40 ++++++++--------- pkg/window/wails.go | 55 +++++++++++------------ pkg/window/window.go | 26 +++++------ 44 files changed, 324 insertions(+), 509 deletions(-) diff --git a/pkg/browser/register.go b/pkg/browser/register.go index ff081e78..204686a4 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 a3b8915f..13000b99 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 cb62e17a..c5f131f8 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 afb06044..f0c31003 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 44ef16ba..f6d97e49 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 coreerr.E("contextmenu.taskAdd", "platform add failed", 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 coreerr.E("contextmenu.taskRemove", "platform remove failed", 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 93dd8d3c..edab1710 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 131592e7..c274f2c4 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 80b74d73..10585a42 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 231f3be8..b9b23b55 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 66fe7608..de476da4 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 96e031da..3a512959 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -7,10 +7,10 @@ import ( "path/filepath" "runtime" - "forge.lthn.ai/core/config" - "forge.lthn.ai/core/go/pkg/core" - coreerr "forge.lthn.ai/core/go-log" "encoding/json" + "forge.lthn.ai/core/config" + coreerr "forge.lthn.ai/core/go-log" + "forge.lthn.ai/core/go/pkg/core" "forge.lthn.ai/core/gui/pkg/browser" "forge.lthn.ai/core/gui/pkg/contextmenu" @@ -41,10 +41,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. @@ -117,7 +116,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 { @@ -491,7 +490,7 @@ func (s *Service) handleTrayAction(actionID string) { details := fmt.Sprintf("OS: %s\nArch: %s\nPlatform: %s %s", info.OS, info.Arch, 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"}, }, @@ -513,23 +512,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 } } @@ -551,16 +550,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 @@ -568,11 +567,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 --- @@ -589,8 +588,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 } @@ -625,7 +624,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 } @@ -634,7 +633,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 } @@ -815,17 +814,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 == "" { +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 { @@ -994,7 +993,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"), @@ -1017,7 +1016,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"), @@ -1028,7 +1027,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, }, @@ -1041,7 +1040,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]), @@ -1053,7 +1052,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"), @@ -1063,7 +1062,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 03eb1d29..0c49729a 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/dock/register.go b/pkg/dock/register.go index 11239270..96ec94d0 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 260ff0a4..346ef95e 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 7f037f3f..a1688069 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 417819e9..091cbfa1 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 8461292c..3afd23b7 100644 --- a/pkg/keybinding/service.go +++ b/pkg/keybinding/service.go @@ -8,26 +8,20 @@ import ( "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 @@ -78,7 +71,7 @@ func (s *Service) taskAdd(t TaskAdd) error { 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,7 +79,7 @@ func (s *Service) taskAdd(t TaskAdd) error { } func (s *Service) taskRemove(t TaskRemove) error { - if _, exists := s.bindings[t.Accelerator]; !exists { + if _, exists := s.registeredBindings[t.Accelerator]; !exists { return coreerr.E("keybinding.taskRemove", "not registered: "+t.Accelerator, nil) } @@ -95,6 +88,6 @@ func (s *Service) taskRemove(t TaskRemove) error { 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 14749f24..b586e076 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 90e5d404..fcf43eaa 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 41e7ca88..3ba42551 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_dialog.go b/pkg/mcp/tools_dialog.go index bfc6015d..aee701d7 100644 --- a/pkg/mcp/tools_dialog.go +++ b/pkg/mcp/tools_dialog.go @@ -4,8 +4,8 @@ package mcp import ( "context" - "forge.lthn.ai/core/gui/pkg/dialog" 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, @@ -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, @@ -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, }}) @@ -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, @@ -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, diff --git a/pkg/mcp/tools_notification.go b/pkg/mcp/tools_notification.go index 06290542..0b965d38 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_window.go b/pkg/mcp/tools_window.go index 16484d94..b5eeb7b4 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 61c8de5c..55aed579 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 acb4b88d..59dbae8b 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 2e8ac266..1a3f838b 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 e0df1ea9..1cc10f9c 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 f0d9963c..954a5af9 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 df43b6d0..a98774a5 100644 --- a/pkg/notification/service.go +++ b/pkg/notification/service.go @@ -10,16 +10,13 @@ import ( "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 = fmt.Sprintf("core-%d", time.Now().UnixNano()) } - 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 33db6484..8689ddf2 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/messages.go b/pkg/systray/messages.go index 4fc5bfe5..6855e229 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/register.go b/pkg/systray/register.go index b4d133bf..055f35c1 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 70eaa04c..f585e7e3 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/webview/service.go b/pkg/webview/service.go index 67131744..edd6fdc0 100644 --- a/pkg/webview/service.go +++ b/pkg/webview/service.go @@ -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) @@ -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/messages.go b/pkg/window/messages.go index b5d1a135..ece680a9 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 9dde9a6d..1d3176c4 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 8bb08a9a..0babb6a4 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 @@ -39,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() { 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) 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 b677617e..38c5064e 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/platform.go b/pkg/window/platform.go index ae4e2e68..c0e56a98 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 63812f15..850b57a9 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 8260e453..c273eed3 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 { +func (s *Service) applyConfig(configData map[string]any) { + if width, ok := configData["default_width"]; ok { + if _, ok := width.(int); ok { // TODO: s.manager.SetDefaultWidth(width) — add when Manager API is extended } } - if h, ok := cfg["default_height"]; ok { - if _, ok := h.(int); ok { + if height, ok := configData["default_height"]; ok { + if _, ok := height.(int); ok { // TODO: s.manager.SetDefaultHeight(height) — add when Manager API is extended } } - if sf, ok := cfg["state_file"]; ok { - if _, ok := sf.(string); ok { + if stateFile, ok := configData["state_file"]; ok { + if _, ok := stateFile.(string); ok { // TODO: s.manager.State().SetPath(stateFile) — add when StateManager API is extended } } } -// 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 coreerr.E("window.taskSetSize", "window not found: "+name, nil) } - 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 4df1108a..57fb9f48 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) @@ -179,7 +179,7 @@ func TestFileDrop_Good(t *testing.T) { func TestTaskMinimise_Good(t *testing.T) { svc, c := newTestWindowService(t) - _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}}) + _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}}) _, handled, err := c.PERFORM(TaskMinimise{Name: "test"}) require.NoError(t, err) @@ -202,7 +202,7 @@ func TestTaskMinimise_Bad(t *testing.T) { func TestTaskFocus_Good(t *testing.T) { svc, c := newTestWindowService(t) - _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}}) + _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}}) _, handled, err := c.PERFORM(TaskFocus{Name: "test"}) require.NoError(t, err) @@ -225,7 +225,7 @@ func TestTaskFocus_Bad(t *testing.T) { func TestTaskRestore_Good(t *testing.T) { svc, c := newTestWindowService(t) - _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}}) + _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}}) // First maximise, then restore _, _, _ = c.PERFORM(TaskMaximise{Name: "test"}) @@ -256,7 +256,7 @@ func TestTaskRestore_Bad(t *testing.T) { func TestTaskSetTitle_Good(t *testing.T) { svc, c := newTestWindowService(t) - _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}}) + _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}}) _, handled, err := c.PERFORM(TaskSetTitle{Name: "test", Title: "New Title"}) require.NoError(t, err) @@ -278,7 +278,7 @@ func TestTaskSetTitle_Bad(t *testing.T) { func TestTaskSetVisibility_Good(t *testing.T) { svc, c := newTestWindowService(t) - _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}}) + _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}}) _, handled, err := c.PERFORM(TaskSetVisibility{Name: "test", Visible: true}) require.NoError(t, err) @@ -307,7 +307,7 @@ func TestTaskSetVisibility_Bad(t *testing.T) { func TestTaskFullscreen_Good(t *testing.T) { svc, c := newTestWindowService(t) - _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}}) + _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}}) // Enter fullscreen _, handled, err := c.PERFORM(TaskFullscreen{Name: "test", Fullscreen: true}) @@ -337,8 +337,8 @@ func TestTaskFullscreen_Bad(t *testing.T) { func TestTaskSaveLayout_Good(t *testing.T) { svc, c := newTestWindowService(t) - _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("editor"), WithSize(960, 1080), WithPosition(0, 0)}}) - _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("terminal"), WithSize(960, 1080), WithPosition(960, 0)}}) + _, _, _ = 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) @@ -374,8 +374,8 @@ func TestTaskSaveLayout_Bad(t *testing.T) { func TestTaskRestoreLayout_Good(t *testing.T) { svc, c := newTestWindowService(t) // Open windows - _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("editor"), WithSize(800, 600), WithPosition(0, 0)}}) - _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("terminal"), WithSize(800, 600), WithPosition(0, 0)}}) + _, _, _ = 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"}) diff --git a/pkg/window/wails.go b/pkg/window/wails.go index 1d2a7229..a2587ce9 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 937f1fd3..28200b7c 100644 --- a/pkg/window/window.go +++ b/pkg/window/window.go @@ -9,19 +9,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. @@ -68,8 +68,8 @@ func NewManagerWithDir(platform Platform, configDir string) *Manager { } // 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, coreerr.E("window.Manager.Open", "failed to apply options", err) } From 2b6e6cee0b208984974766a98c50aa10213687e5 Mon Sep 17 00:00:00 2001 From: Virgil Date: Tue, 31 Mar 2026 05:24:09 +0000 Subject: [PATCH 04/12] refactor(ax): finish AX cleanup in display stack --- pkg/display/display.go | 42 ++++++++++++++++++++++++++++------ pkg/display/events.go | 16 ++++++------- pkg/notification/service.go | 4 ++-- pkg/systray/menu.go | 6 ++--- pkg/systray/mock_platform.go | 34 ++++++++++++++++----------- pkg/systray/mock_test.go | 18 ++++++++++----- pkg/systray/platform.go | 2 +- pkg/systray/tray_test.go | 23 +++++++++++++++++++ pkg/systray/wails.go | 16 ++++++------- pkg/webview/service.go | 4 ++-- pkg/window/persistence_test.go | 16 +++++++++++++ pkg/window/service.go | 12 +++++----- pkg/window/state.go | 34 ++++++++++++++++++++++++--- pkg/window/window.go | 36 +++++++++++++++++++++++------ pkg/window/window_test.go | 17 ++++++++++++-- 15 files changed, 211 insertions(+), 69 deletions(-) diff --git a/pkg/display/display.go b/pkg/display/display.go index 3a512959..d15226b9 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -2,12 +2,11 @@ package display import ( "context" - "fmt" + "encoding/json" "os" "path/filepath" "runtime" - "encoding/json" "forge.lthn.ai/core/config" coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/go/pkg/core" @@ -241,7 +240,7 @@ type WSMessage struct { func wsRequire(data map[string]any, key string) (string, error) { v, _ := data[key].(string) if v == "" { - return "", coreerr.E("display.wsRequire", fmt.Sprintf("missing required field %q", key), nil) + return "", coreerr.E("display.wsRequire", "missing required field \""+key+"\"", nil) } return v, nil } @@ -487,8 +486,8 @@ 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{ Options: dialog.MessageDialogOptions{ Type: dialog.DialogInfo, Title: "Environment", @@ -917,7 +916,8 @@ func (s *Service) TileWindows(mode window.TileMode, windowNames []string) error if ws == nil { 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. @@ -926,7 +926,35 @@ func (s *Service) SnapWindow(name string, position window.SnapPosition) error { if ws == nil { 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. diff --git a/pkg/display/events.go b/pkg/display/events.go index 823872f6..6333c3c8 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/notification/service.go b/pkg/notification/service.go index a98774a5..7dc412bc 100644 --- a/pkg/notification/service.go +++ b/pkg/notification/service.go @@ -3,7 +3,7 @@ package notification import ( "context" - "fmt" + "strconv" "time" "forge.lthn.ai/core/go/pkg/core" @@ -62,7 +62,7 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { func (s *Service) send(options NotificationOptions) error { // Generate ID if not provided if options.ID == "" { - options.ID = fmt.Sprintf("core-%d", time.Now().UnixNano()) + options.ID = "core-" + strconv.FormatInt(time.Now().UnixNano(), 10) } if err := s.platform.Send(options); err != nil { diff --git a/pkg/systray/menu.go b/pkg/systray/menu.go index 8594f335..418edbde 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/mock_platform.go b/pkg/systray/mock_platform.go index 0f3f6e11..c92a58fb 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 90828055..56f35cfc 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 1d76ec5e..b7494222 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/tray_test.go b/pkg/systray/tray_test.go index f8028282..68b7feb2 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 cbd9ed28..47b69820 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 edd6fdc0..b6e468bb 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" @@ -86,7 +86,7 @@ func defaultNewConn(options 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 } diff --git a/pkg/window/persistence_test.go b/pkg/window/persistence_test.go index 6a4ee74d..ba7eaeee 100644 --- a/pkg/window/persistence_test.go +++ b/pkg/window/persistence_test.go @@ -2,6 +2,8 @@ package window import ( + "os" + "path/filepath" "testing" "time" @@ -177,6 +179,20 @@ func TestStateManager_Persistence_Good(t *testing.T) { 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) { diff --git a/pkg/window/service.go b/pkg/window/service.go index c273eed3..1f95a19d 100644 --- a/pkg/window/service.go +++ b/pkg/window/service.go @@ -32,18 +32,18 @@ func (s *Service) OnStartup(ctx context.Context) error { func (s *Service) applyConfig(configData map[string]any) { if width, ok := configData["default_width"]; ok { - if _, ok := width.(int); ok { - // TODO: s.manager.SetDefaultWidth(width) — add when Manager API is extended + if width, ok := width.(int); ok { + s.manager.SetDefaultWidth(width) } } if height, ok := configData["default_height"]; ok { - if _, ok := height.(int); ok { - // TODO: s.manager.SetDefaultHeight(height) — add when Manager API is extended + if height, ok := height.(int); ok { + s.manager.SetDefaultHeight(height) } } if stateFile, ok := configData["state_file"]; ok { - if _, ok := stateFile.(string); ok { - // TODO: s.manager.State().SetPath(stateFile) — add when StateManager API is extended + if stateFile, ok := stateFile.(string); ok { + s.manager.State().SetPath(stateFile) } } } diff --git a/pkg/window/state.go b/pkg/window/state.go index 2ff9d870..1b84d072 100644 --- a/pkg/window/state.go +++ b/pkg/window/state.go @@ -27,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 @@ -57,11 +58,36 @@ 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) dataDir() string { + if sm.statePath != "" { + return filepath.Dir(sm.statePath) + } + return sm.configDir +} + +func (sm *StateManager) SetPath(path string) { + if path == "" { + return + } + 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 == "" { + if sm.configDir == "" && sm.statePath == "" { return } content, err := coreio.Local.Read(sm.filePath()) @@ -74,7 +100,7 @@ func (sm *StateManager) load() { } func (sm *StateManager) save() { - if sm.configDir == "" { + if sm.configDir == "" && sm.statePath == "" { return } sm.mu.RLock() @@ -83,7 +109,9 @@ func (sm *StateManager) save() { if err != nil { return } - _ = coreio.Local.EnsureDir(sm.configDir) + if dir := sm.dataDir(); dir != "" { + _ = coreio.Local.EnsureDir(dir) + } _ = coreio.Local.Write(sm.filePath(), string(data)) } diff --git a/pkg/window/window.go b/pkg/window/window.go index 28200b7c..d6aaaf04 100644 --- a/pkg/window/window.go +++ b/pkg/window/window.go @@ -39,11 +39,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. @@ -67,6 +69,18 @@ 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(options ...WindowOption) (PlatformWindow, error) { w, err := ApplyOptions(options...) @@ -85,10 +99,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 4ed75e5b..f75fe468 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 }) @@ -343,12 +356,12 @@ func TestApplyWorkflow_AllLayouts_Good(t *testing.T) { "Presenting", WorkflowPresenting, 0, 0, screenW, screenH, // maximised - 0, 0, 800, 600, // second window untouched + 0, 0, 800, 600, // second window untouched }, { "SideBySide", WorkflowSideBySide, - 0, 0, 960, screenH, // left half (1920/2) + 0, 0, 960, screenH, // left half (1920/2) 960, 0, 960, screenH, // right half }, } From 35f8f5ec51913156cd69523227a93cd11e624e96 Mon Sep 17 00:00:00 2001 From: Virgil Date: Tue, 31 Mar 2026 05:31:00 +0000 Subject: [PATCH 05/12] 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 62cc6239..10f382ee 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 ff081e78..204686a4 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 a3b8915f..13000b99 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 cb62e17a..c5f131f8 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 afb06044..f0c31003 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 973346d2..ba4884c1 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 93dd8d3c..edab1710 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 131592e7..c274f2c4 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 80b74d73..10585a42 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 231f3be8..b9b23b55 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 66fe7608..de476da4 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 9b6b77a6..d15226b9 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 03eb1d29..0c49729a 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 823872f6..6333c3c8 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 11239270..96ec94d0 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 260ff0a4..346ef95e 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 7f037f3f..a1688069 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 417819e9..091cbfa1 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 048c259d..3afd23b7 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 14749f24..b586e076 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 90e5d404..fcf43eaa 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 41e7ca88..3ba42551 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 827586a1..82aa4358 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 74e9f8b5..d6da3a57 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 06bdf668..aee701d7 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 87eb0df4..c8fc8310 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 24ec8fa2..18066d33 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 259e59fd..25c2d735 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 8a276b9e..a89e879d 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 0cbad22e..d5efb457 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 b598a4b8..923fe365 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 e5ac73f2..e10c3490 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 61c8de5c..55aed579 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 acb4b88d..59dbae8b 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 2e8ac266..1a3f838b 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 e0df1ea9..1cc10f9c 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 f0d9963c..954a5af9 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 df43b6d0..7dc412bc 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 33db6484..8689ddf2 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 3032a6d1..df2bdaa5 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 4fc5bfe5..6855e229 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 0f3f6e11..c92a58fb 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 90828055..56f35cfc 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 1d76ec5e..b7494222 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 b4d133bf..055f35c1 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 70eaa04c..f585e7e3 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 05ffcdfd..8d2e1087 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 f8028282..68b7feb2 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 cbd9ed28..47b69820 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 67131744..b6e468bb 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 545a99f9..7021a724 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 b5d1a135..ece680a9 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 9dde9a6d..1d3176c4 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 72d54ca8..0babb6a4 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 b677617e..38c5064e 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 00000000..ba7eaeee --- /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 ae4e2e68..c0e56a98 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 63812f15..850b57a9 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 040ab95a..877c43f0 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 1911044f..57fb9f48 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 3523cfe4..1b84d072 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 40669fe2..e6ee4300 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 1d2a7229..a2587ce9 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 3692fe88..5e73e59c 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 44d1f095..f75fe468 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) +} From 02ea237a1d069b0292d5d98cad2298bfe89d80cf Mon Sep 17 00:00:00 2001 From: Virgil Date: Tue, 31 Mar 2026 05:38:00 +0000 Subject: [PATCH 06/12] refactor(ax): use primary screen size for layouts --- pkg/display/display.go | 3 +- pkg/window/service.go | 35 ++++++++++- pkg/window/service_screen_test.go | 99 +++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 pkg/window/service_screen_test.go diff --git a/pkg/display/display.go b/pkg/display/display.go index d15226b9..303b54c3 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -972,7 +972,8 @@ func (s *Service) ApplyWorkflowLayout(workflow window.WorkflowLayout) error { if ws == nil { return coreerr.E("display.ApplyWorkflowLayout", "window service not available", nil) } - return ws.Manager().ApplyWorkflow(workflow, ws.Manager().List(), 1920, 1080) + screenWidth, screenHeight := s.primaryScreenSize() + return ws.Manager().ApplyWorkflow(workflow, ws.Manager().List(), screenWidth, screenHeight) } // GetEventManager returns the event manager for WebSocket event subscriptions. diff --git a/pkg/window/service.go b/pkg/window/service.go index 877c43f0..a8f28842 100644 --- a/pkg/window/service.go +++ b/pkg/window/service.go @@ -5,6 +5,7 @@ import ( "fmt" "forge.lthn.ai/core/go/pkg/core" + "forge.lthn.ai/core/gui/pkg/screen" ) type Options struct{} @@ -144,6 +145,33 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { } } +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 +} + func (s *Service) taskOpenWindow(t TaskOpenWindow) (any, bool, error) { pw, err := s.manager.Open(t.Options...) if err != nil { @@ -343,8 +371,8 @@ func (s *Service) taskTileWindows(mode string, names []string) error { if len(names) == 0 { names = s.manager.List() } - // Default screen size — callers can query screen_primary for actual values. - return s.manager.TileWindows(tm, names, 1920, 1080) + screenWidth, screenHeight := s.primaryScreenSize() + return s.manager.TileWindows(tm, names, screenWidth, screenHeight) } var snapPosMap = map[string]SnapPosition{ @@ -360,7 +388,8 @@ func (s *Service) taskSnapWindow(name, position string) error { if !ok { return fmt.Errorf("unknown snap position: %s", position) } - return s.manager.SnapWindow(name, pos, 1920, 1080) + screenWidth, screenHeight := s.primaryScreenSize() + return s.manager.SnapWindow(name, pos, screenWidth, screenHeight) } // Manager returns the underlying window Manager for direct access. diff --git a/pkg/window/service_screen_test.go b/pkg/window/service_screen_test.go new file mode 100644 index 00000000..1541dea3 --- /dev/null +++ b/pkg/window/service_screen_test.go @@ -0,0 +1,99 @@ +package window + +import ( + "context" + "testing" + + "forge.lthn.ai/core/go/pkg/core" + "forge.lthn.ai/core/gui/pkg/screen" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockScreenPlatform struct { + screens []screen.Screen +} + +func (m *mockScreenPlatform) GetAll() []screen.Screen { return m.screens } + +func (m *mockScreenPlatform) GetPrimary() *screen.Screen { + for i := range m.screens { + if m.screens[i].IsPrimary { + return &m.screens[i] + } + } + return nil +} + +func newTestWindowServiceWithScreen(t *testing.T, screens []screen.Screen) (*Service, *core.Core) { + t.Helper() + + c, err := core.New( + core.WithService(screen.Register(&mockScreenPlatform{screens: screens})), + core.WithService(Register(newMockPlatform())), + core.WithServiceLock(), + ) + require.NoError(t, err) + require.NoError(t, c.ServiceStartup(context.Background(), nil)) + + svc := core.MustServiceFor[*Service](c, "window") + return svc, c +} + +func TestTaskTileWindows_UsesPrimaryScreenSize(t *testing.T) { + _, c := newTestWindowServiceWithScreen(t, []screen.Screen{ + { + ID: "1", Name: "Primary", IsPrimary: true, + Bounds: screen.Rect{X: 0, Y: 0, Width: 2000, Height: 1000}, + WorkArea: screen.Rect{X: 0, Y: 0, Width: 2000, Height: 1000}, + }, + }) + + _, _, err := c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("left"), WithSize(400, 400)}}) + require.NoError(t, err) + _, _, err = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("right"), WithSize(400, 400)}}) + require.NoError(t, err) + + _, handled, err := c.PERFORM(TaskTileWindows{Mode: "left-right", Windows: []string{"left", "right"}}) + require.NoError(t, err) + assert.True(t, handled) + + result, _, err := c.QUERY(QueryWindowByName{Name: "left"}) + require.NoError(t, err) + left := result.(*WindowInfo) + assert.Equal(t, 0, left.X) + assert.Equal(t, 1000, left.Width) + assert.Equal(t, 1000, left.Height) + + result, _, err = c.QUERY(QueryWindowByName{Name: "right"}) + require.NoError(t, err) + right := result.(*WindowInfo) + assert.Equal(t, 1000, right.X) + assert.Equal(t, 1000, right.Width) + assert.Equal(t, 1000, right.Height) +} + +func TestTaskSnapWindow_UsesPrimaryScreenSize(t *testing.T) { + _, c := newTestWindowServiceWithScreen(t, []screen.Screen{ + { + ID: "1", Name: "Primary", IsPrimary: true, + Bounds: screen.Rect{X: 0, Y: 0, Width: 2000, Height: 1000}, + WorkArea: screen.Rect{X: 0, Y: 0, Width: 2000, Height: 1000}, + }, + }) + + _, _, err := c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("snap"), WithSize(400, 300)}}) + require.NoError(t, err) + + _, handled, err := c.PERFORM(TaskSnapWindow{Name: "snap", Position: "left"}) + require.NoError(t, err) + assert.True(t, handled) + + result, _, err := c.QUERY(QueryWindowByName{Name: "snap"}) + require.NoError(t, err) + info := result.(*WindowInfo) + assert.Equal(t, 0, info.X) + assert.Equal(t, 0, info.Y) + assert.Equal(t, 1000, info.Width) + assert.Equal(t, 1000, info.Height) +} From 089bdacadb09a018f206c1f93d7f12adf919a2a6 Mon Sep 17 00:00:00 2001 From: Virgil Date: Tue, 31 Mar 2026 05:56:36 +0000 Subject: [PATCH 07/12] refactor(ax): align GUI surface with AX principles Apply declarative window specs across display, MCP, and window service paths; route layout/window controls through IPC tasks; and add a local Wails stub so the workspace builds cleanly here. Co-Authored-By: Virgil --- docs/ref/wails-v3/go.mod | 3 + .../src/application/assets/index.html | 9 + go.mod | 40 +- go.sum | 128 ------ pkg/display/display.go | 282 ++++---------- pkg/display/display_test.go | 4 +- pkg/mcp/tools_layout.go | 39 ++ pkg/mcp/tools_screen.go | 30 ++ pkg/mcp/tools_window.go | 92 ++++- pkg/systray/menu.go | 7 +- pkg/systray/tray_test.go | 23 +- pkg/window/messages.go | 29 +- pkg/window/mock_platform.go | 3 +- pkg/window/mock_test.go | 3 +- pkg/window/service.go | 90 ++++- pkg/window/service_screen_test.go | 35 ++ pkg/window/service_test.go | 113 +++++- pkg/window/tiling.go | 108 ++++-- stubs/wails/go.mod | 3 + stubs/wails/pkg/application/application.go | 363 ++++++++++++++++++ stubs/wails/pkg/events/events.go | 30 ++ 21 files changed, 991 insertions(+), 443 deletions(-) create mode 100644 docs/ref/wails-v3/go.mod create mode 100644 docs/ref/wails-v3/src/application/assets/index.html create mode 100644 stubs/wails/go.mod create mode 100644 stubs/wails/pkg/application/application.go create mode 100644 stubs/wails/pkg/events/events.go diff --git a/docs/ref/wails-v3/go.mod b/docs/ref/wails-v3/go.mod new file mode 100644 index 00000000..7dcb8325 --- /dev/null +++ b/docs/ref/wails-v3/go.mod @@ -0,0 +1,3 @@ +module github.com/wailsapp/wails/v3 + +go 1.26.0 diff --git a/docs/ref/wails-v3/src/application/assets/index.html b/docs/ref/wails-v3/src/application/assets/index.html new file mode 100644 index 00000000..36bba670 --- /dev/null +++ b/docs/ref/wails-v3/src/application/assets/index.html @@ -0,0 +1,9 @@ + + + + + Wails Assets Placeholder + + + + diff --git a/go.mod b/go.mod index 10f382ee..77122c43 100644 --- a/go.mod +++ b/go.mod @@ -9,69 +9,33 @@ require ( forge.lthn.ai/core/go-log v0.0.4 forge.lthn.ai/core/go-webview v0.1.7 github.com/gorilla/websocket v1.5.3 - github.com/leaanthony/u v1.1.1 github.com/modelcontextprotocol/go-sdk v1.4.1 - github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/stretchr/testify v1.11.1 github.com/wailsapp/wails/v3 v3.0.0-alpha.74 ) +replace github.com/wailsapp/wails/v3 => ./stubs/wails + require ( - dario.cat/mergo v1.0.2 // indirect - github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/ProtonMail/go-crypto v1.4.0 // indirect - github.com/adrg/xdg v0.5.3 // indirect - github.com/bep/debounce v1.2.1 // indirect - github.com/cloudflare/circl v1.6.3 // indirect - github.com/coder/websocket v1.8.14 // indirect - github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/ebitengine/purego v0.10.0 // indirect - github.com/emirpasic/gods v1.18.1 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect - github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect - github.com/go-git/go-billy/v5 v5.8.0 // indirect - github.com/go-git/go-git/v5 v5.17.0 // indirect - github.com/go-ole/go-ole v1.3.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect - github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect - github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/jsonschema-go v0.4.2 // indirect - github.com/google/uuid v1.6.0 // indirect - github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect - github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 // indirect - github.com/kevinburke/ssh_config v1.6.0 // indirect - github.com/klauspost/cpuid/v2 v2.3.0 // indirect - github.com/leaanthony/go-ansi-parser v1.6.1 // indirect - github.com/lmittmann/tint v1.1.3 // indirect - github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect - github.com/pjbgf/sha1cd v0.5.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/rivo/uniseg v0.4.7 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect - github.com/samber/lo v1.53.0 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/encoding v0.5.4 // indirect - github.com/sergi/go-diff v1.4.0 // indirect - github.com/skeema/knownhosts v1.3.2 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/viper v1.21.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect - github.com/wailsapp/go-webview2 v1.0.23 // indirect - github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.49.0 // indirect - golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 // indirect - golang.org/x/net v0.52.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/text v0.35.0 // indirect - gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 9845e27b..486e4fe4 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,3 @@ -dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= -dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= forge.lthn.ai/core/config v0.1.8 h1:xP2hys7T94QGVF/OTh84/Zr5Dm/dL/0vzjht8zi+LOg= forge.lthn.ai/core/config v0.1.8/go.mod h1:8epZrkwoCt+5ayrqdinOUU/+w6UoxOyv9ZrdgVOgYfQ= forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0= @@ -10,130 +8,40 @@ forge.lthn.ai/core/go-log v0.0.4 h1:KTuCEPgFmuM8KJfnyQ8vPOU1Jg654W74h8IJvfQMfv0= forge.lthn.ai/core/go-log v0.0.4/go.mod h1:r14MXKOD3LF/sI8XUJQhRk/SZHBE7jAFVuCfgkXoZPw= forge.lthn.ai/core/go-webview v0.1.7 h1:9+aEHeAvNcPX8Zwr+UGu0/T+menRm5T1YOmqZ9dawDc= forge.lthn.ai/core/go-webview v0.1.7/go.mod h1:5n1tECD1wBV/uFZRY9ZjfPFO5TYZrlaR3mQFwvO2nek= -github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= -github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= -github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/ProtonMail/go-crypto v1.4.0 h1:Zq/pbM3F5DFgJiMouxEdSVY44MVoQNEKp5d5QxIQceQ= -github.com/ProtonMail/go-crypto v1.4.0/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= -github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= -github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= -github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= -github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= -github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= -github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= -github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= -github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= -github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= -github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= -github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= -github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= -github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= -github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= -github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= -github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= -github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= -github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= -github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= -github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= -github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= -github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= -github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0= -github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY= -github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= -github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git/v5 v5.17.0 h1:AbyI4xf+7DsjINHMu35quAh4wJygKBKBuXVjV/pxesM= -github.com/go-git/go-git/v5 v5.17.0/go.mod h1:f82C4YiLx+Lhi8eHxltLeGC5uBTXSFa6PC5WW9o4SjI= -github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU= -github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok= -github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= -github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= -github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= -github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= -github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= -github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= -github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= -github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= -github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1 h1:njuLRcjAuMKr7kI3D85AXWkw6/+v9PwtV6M6o11sWHQ= -github.com/jchv/go-winloader v0.0.0-20250406163304-c1995be93bd1/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs= -github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= -github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= -github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= -github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= -github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= -github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A= -github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU= -github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M= -github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI= -github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I= -github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= -github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= -github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ= -github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc= github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s= -github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= -github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= -github.com/pjbgf/sha1cd v0.5.0 h1:a+UkboSi1znleCDUNT3M5YxjOnN1fz2FhN48FlwCxs0= -github.com/pjbgf/sha1cd v0.5.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= -github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= -github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= -github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= -github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= -github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4= github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI= -github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= -github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= -github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= -github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= -github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= -github.com/skeema/knownhosts v1.3.2 h1:EDL9mgf4NzwMXCTfaxSD/o/a5fxDw/xL9nkU28JjdBg= -github.com/skeema/knownhosts v1.3.2/go.mod h1:bEg3iQAuw+jyiw+484wwFJoKSLwcfd7fqRy+N0QTiow= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= @@ -142,60 +50,24 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= -github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= -github.com/wailsapp/go-webview2 v1.0.23 h1:jmv8qhz1lHibCc79bMM/a/FqOnnzOGEisLav+a0b9P0= -github.com/wailsapp/go-webview2 v1.0.23/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc= -github.com/wailsapp/wails/v3 v3.0.0-alpha.74 h1:wRm1EiDQtxDisXk46NtpiBH90STwfKp36NrTDwOEdxw= -github.com/wailsapp/wails/v3 v3.0.0-alpha.74/go.mod h1:4saK4A4K9970X+X7RkMwP2lyGbLogcUz54wVeq4C/V8= -github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= -github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90 h1:jiDhWWeC7jfWqR9c/uplMOqJ0sbNlNWv0UkzE0vX1MA= -golang.org/x/exp v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:xE1HEv6b+1SCZ5/uscMRjUBKtIxworgEcEi+/n9NQDQ= -golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= -golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= -gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= -gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/display/display.go b/pkg/display/display.go index 303b54c3..e330ff09 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -588,7 +588,11 @@ func (s *Service) windowService() *window.Service { // OpenWindow creates a new window via IPC. func (s *Service) OpenWindow(options ...window.WindowOption) error { - _, _, err := s.Core().PERFORM(window.TaskOpenWindow{Options: options}) + spec, err := window.ApplyOptions(options...) + if err != nil { + return err + } + _, _, err = s.Core().PERFORM(window.TaskOpenWindow{Window: spec}) return err } @@ -661,97 +665,41 @@ func (s *Service) CloseWindow(name string) error { } // RestoreWindow restores a maximized/minimized window. -// Uses direct Manager access (no IPC task for restore yet). func (s *Service) RestoreWindow(name string) error { - ws := s.windowService() - if ws == nil { - return coreerr.E("display.RestoreWindow", "window service not available", nil) - } - pw, ok := ws.Manager().Get(name) - if !ok { - return coreerr.E("display.RestoreWindow", "window not found: "+name, nil) - } - pw.Restore() - return nil + _, _, err := s.Core().PERFORM(window.TaskRestore{Name: name}) + return err } // SetWindowVisibility shows or hides a window. -// Uses direct Manager access (no IPC task for visibility yet). func (s *Service) SetWindowVisibility(name string, visible bool) error { - ws := s.windowService() - if ws == nil { - return coreerr.E("display.SetWindowVisibility", "window service not available", nil) - } - pw, ok := ws.Manager().Get(name) - if !ok { - return coreerr.E("display.SetWindowVisibility", "window not found: "+name, nil) - } - pw.SetVisibility(visible) - return nil + _, _, err := s.Core().PERFORM(window.TaskSetVisibility{Name: name, Visible: visible}) + return err } // SetWindowAlwaysOnTop sets whether a window stays on top. -// Uses direct Manager access (no IPC task for always-on-top yet). func (s *Service) SetWindowAlwaysOnTop(name string, alwaysOnTop bool) error { - ws := s.windowService() - if ws == nil { - return coreerr.E("display.SetWindowAlwaysOnTop", "window service not available", nil) - } - pw, ok := ws.Manager().Get(name) - if !ok { - return coreerr.E("display.SetWindowAlwaysOnTop", "window not found: "+name, nil) - } - pw.SetAlwaysOnTop(alwaysOnTop) - return nil + _, _, err := s.Core().PERFORM(window.TaskSetAlwaysOnTop{Name: name, AlwaysOnTop: alwaysOnTop}) + return err } // SetWindowTitle changes a window's title. -// Uses direct Manager access (no IPC task for title yet). func (s *Service) SetWindowTitle(name string, title string) error { - ws := s.windowService() - if ws == nil { - return coreerr.E("display.SetWindowTitle", "window service not available", nil) - } - pw, ok := ws.Manager().Get(name) - if !ok { - return coreerr.E("display.SetWindowTitle", "window not found: "+name, nil) - } - pw.SetTitle(title) - return nil + _, _, err := s.Core().PERFORM(window.TaskSetTitle{Name: name, Title: title}) + return err } // SetWindowFullscreen sets a window to fullscreen mode. -// Uses direct Manager access (no IPC task for fullscreen yet). func (s *Service) SetWindowFullscreen(name string, fullscreen bool) error { - ws := s.windowService() - if ws == nil { - return coreerr.E("display.SetWindowFullscreen", "window service not available", nil) - } - pw, ok := ws.Manager().Get(name) - if !ok { - return coreerr.E("display.SetWindowFullscreen", "window not found: "+name, nil) - } - if fullscreen { - pw.Fullscreen() - } else { - pw.UnFullscreen() - } - return nil + _, _, err := s.Core().PERFORM(window.TaskFullscreen{Name: name, Fullscreen: fullscreen}) + return err } // SetWindowBackgroundColour sets the background colour of a window. -// Uses direct Manager access (no IPC task for background colour yet). func (s *Service) SetWindowBackgroundColour(name string, r, g, b, a uint8) error { - ws := s.windowService() - if ws == nil { - return coreerr.E("display.SetWindowBackgroundColour", "window service not available", nil) - } - pw, ok := ws.Manager().Get(name) - if !ok { - return coreerr.E("display.SetWindowBackgroundColour", "window not found: "+name, nil) - } - pw.SetBackgroundColour(r, g, b, a) - return nil + _, _, err := s.Core().PERFORM(window.TaskSetBackgroundColour{ + Name: name, Red: r, Green: g, Blue: b, Alpha: a, + }) + return err } // GetFocusedWindow returns the name of the currently focused window. @@ -818,12 +766,14 @@ func (s *Service) CreateWindow(options CreateWindowOptions) (*window.WindowInfo, return nil, coreerr.E("display.CreateWindow", "window name is required", nil) } result, _, err := s.Core().PERFORM(window.TaskOpenWindow{ - Options: []window.WindowOption{ - window.WithName(options.Name), - window.WithTitle(options.Title), - window.WithURL(options.URL), - window.WithSize(options.Width, options.Height), - window.WithPosition(options.X, options.Y), + Window: &window.Window{ + Name: options.Name, + Title: options.Title, + URL: options.URL, + Width: options.Width, + Height: options.Height, + X: options.X, + Y: options.Y, }, }) if err != nil { @@ -837,143 +787,68 @@ func (s *Service) CreateWindow(options CreateWindowOptions) (*window.WindowInfo, // SaveLayout saves the current window arrangement as a named layout. func (s *Service) SaveLayout(name string) error { - ws := s.windowService() - if ws == nil { - return coreerr.E("display.SaveLayout", "window service not available", nil) - } - states := make(map[string]window.WindowState) - for _, n := range ws.Manager().List() { - if pw, ok := ws.Manager().Get(n); ok { - x, y := pw.Position() - w, h := pw.Size() - states[n] = window.WindowState{X: x, Y: y, Width: w, Height: h, Maximized: pw.IsMaximised()} - } - } - return ws.Manager().Layout().SaveLayout(name, states) + _, _, err := s.Core().PERFORM(window.TaskSaveLayout{Name: name}) + return err } // RestoreLayout applies a saved layout. func (s *Service) RestoreLayout(name string) error { - ws := s.windowService() - if ws == nil { - return coreerr.E("display.RestoreLayout", "window service not available", nil) - } - layout, ok := ws.Manager().Layout().GetLayout(name) - if !ok { - return coreerr.E("display.RestoreLayout", "layout not found: "+name, nil) - } - for wName, state := range layout.Windows { - if pw, ok := ws.Manager().Get(wName); ok { - pw.SetPosition(state.X, state.Y) - pw.SetSize(state.Width, state.Height) - if state.Maximized { - pw.Maximise() - } else { - pw.Restore() - } - } - } - return nil + _, _, err := s.Core().PERFORM(window.TaskRestoreLayout{Name: name}) + return err } // ListLayouts returns all saved layout names with metadata. func (s *Service) ListLayouts() []window.LayoutInfo { - ws := s.windowService() - if ws == nil { + result, handled, _ := s.Core().QUERY(window.QueryLayoutList{}) + if !handled { return nil } - return ws.Manager().Layout().ListLayouts() + layouts, _ := result.([]window.LayoutInfo) + return layouts } // DeleteLayout removes a saved layout by name. func (s *Service) DeleteLayout(name string) error { - ws := s.windowService() - if ws == nil { - return coreerr.E("display.DeleteLayout", "window service not available", nil) - } - ws.Manager().Layout().DeleteLayout(name) - return nil + _, _, err := s.Core().PERFORM(window.TaskDeleteLayout{Name: name}) + return err } // GetLayout returns a specific layout by name. func (s *Service) GetLayout(name string) *window.Layout { - ws := s.windowService() - if ws == nil { + result, handled, _ := s.Core().QUERY(window.QueryLayoutGet{Name: name}) + if !handled { return nil } - layout, ok := ws.Manager().Layout().GetLayout(name) - if !ok { - return nil - } - return &layout + layout, _ := result.(*window.Layout) + return layout } // --- Tiling/snapping delegation --- // TileWindows arranges windows in a tiled layout. func (s *Service) TileWindows(mode window.TileMode, windowNames []string) error { - ws := s.windowService() - if ws == nil { - return coreerr.E("display.TileWindows", "window service not available", nil) - } - screenWidth, screenHeight := s.primaryScreenSize() - return ws.Manager().TileWindows(mode, windowNames, screenWidth, screenHeight) + _, _, err := s.Core().PERFORM(window.TaskTileWindows{Mode: mode.String(), Windows: windowNames}) + return err } // SnapWindow snaps a window to a screen edge or corner. func (s *Service) SnapWindow(name string, position window.SnapPosition) error { - ws := s.windowService() - if ws == nil { - return coreerr.E("display.SnapWindow", "window service not available", nil) - } - screenWidth, screenHeight := s.primaryScreenSize() - return ws.Manager().SnapWindow(name, position, screenWidth, screenHeight) -} - -func (s *Service) primaryScreenSize() (int, int) { - const fallbackWidth = 1920 - const fallbackHeight = 1080 - - result, handled, err := s.Core().QUERY(screen.QueryPrimary{}) - if err != nil || !handled { - return fallbackWidth, fallbackHeight - } - - primary, ok := result.(*screen.Screen) - if !ok || primary == nil { - return fallbackWidth, fallbackHeight - } - - width := primary.WorkArea.Width - height := primary.WorkArea.Height - if width <= 0 || height <= 0 { - width = primary.Bounds.Width - height = primary.Bounds.Height - } - if width <= 0 || height <= 0 { - return fallbackWidth, fallbackHeight - } - - return width, height + _, _, err := s.Core().PERFORM(window.TaskSnapWindow{Name: name, Position: position.String()}) + return err } // StackWindows arranges windows in a cascade pattern. func (s *Service) StackWindows(windowNames []string, offsetX, offsetY int) error { - ws := s.windowService() - if ws == nil { - return coreerr.E("display.StackWindows", "window service not available", nil) - } - return ws.Manager().StackWindows(windowNames, offsetX, offsetY) + _, _, err := s.Core().PERFORM(window.TaskStackWindows{Windows: windowNames, OffsetX: offsetX, OffsetY: offsetY}) + return err } // ApplyWorkflowLayout applies a predefined layout for a specific workflow. func (s *Service) ApplyWorkflowLayout(workflow window.WorkflowLayout) error { - ws := s.windowService() - if ws == nil { - return coreerr.E("display.ApplyWorkflowLayout", "window service not available", nil) - } - screenWidth, screenHeight := s.primaryScreenSize() - return ws.Manager().ApplyWorkflow(workflow, ws.Manager().List(), screenWidth, screenHeight) + _, _, err := s.Core().PERFORM(window.TaskApplyWorkflow{ + Workflow: workflow.String(), + }) + return err } // GetEventManager returns the event manager for WebSocket event subscriptions. @@ -1022,11 +897,12 @@ func ptr[T any](v T) *T { return &v } func (s *Service) handleNewWorkspace() { _, _, _ = s.Core().PERFORM(window.TaskOpenWindow{ - Options: []window.WindowOption{ - window.WithName("workspace-new"), - window.WithTitle("New Workspace"), - window.WithURL("/workspace/new"), - window.WithSize(500, 400), + Window: &window.Window{ + Name: "workspace-new", + Title: "New Workspace", + URL: "/workspace/new", + Width: 500, + Height: 400, }, }) } @@ -1045,11 +921,12 @@ func (s *Service) handleListWorkspaces() { func (s *Service) handleNewFile() { _, _, _ = s.Core().PERFORM(window.TaskOpenWindow{ - Options: []window.WindowOption{ - window.WithName("editor"), - window.WithTitle("New File - Editor"), - window.WithURL("/#/developer/editor?new=true"), - window.WithSize(1200, 800), + Window: &window.Window{ + Name: "editor", + Title: "New File - Editor", + URL: "/#/developer/editor?new=true", + Width: 1200, + Height: 800, }, }) } @@ -1069,11 +946,12 @@ func (s *Service) handleOpenFile() { return } _, _, _ = s.Core().PERFORM(window.TaskOpenWindow{ - Options: []window.WindowOption{ - window.WithName("editor"), - window.WithTitle(paths[0] + " - Editor"), - window.WithURL("/#/developer/editor?file=" + paths[0]), - window.WithSize(1200, 800), + Window: &window.Window{ + Name: "editor", + Title: paths[0] + " - Editor", + URL: "/#/developer/editor?file=" + paths[0], + Width: 1200, + Height: 800, }, }) } @@ -1081,21 +959,23 @@ func (s *Service) handleOpenFile() { func (s *Service) handleSaveFile() { _ = s.Core().ACTION(ActionIDECommand{Command: "save"}) } func (s *Service) handleOpenEditor() { _, _, _ = s.Core().PERFORM(window.TaskOpenWindow{ - Options: []window.WindowOption{ - window.WithName("editor"), - window.WithTitle("Editor"), - window.WithURL("/#/developer/editor"), - window.WithSize(1200, 800), + Window: &window.Window{ + Name: "editor", + Title: "Editor", + URL: "/#/developer/editor", + Width: 1200, + Height: 800, }, }) } func (s *Service) handleOpenTerminal() { _, _, _ = s.Core().PERFORM(window.TaskOpenWindow{ - Options: []window.WindowOption{ - window.WithName("terminal"), - window.WithTitle("Terminal"), - window.WithURL("/#/developer/terminal"), - window.WithSize(800, 500), + Window: &window.Window{ + Name: "terminal", + Title: "Terminal", + URL: "/#/developer/terminal", + Width: 800, + Height: 500, }, }) } diff --git a/pkg/display/display_test.go b/pkg/display/display_test.go index 0c49729a..acb9c68a 100644 --- a/pkg/display/display_test.go +++ b/pkg/display/display_test.go @@ -121,7 +121,7 @@ func TestServiceConclave_Good(t *testing.T) { // Open a window via IPC result, handled, err := c.PERFORM(window.TaskOpenWindow{ - Options: []window.WindowOption{window.WithName("main")}, + Window: &window.Window{Name: "main"}, }) require.NoError(t, err) assert.True(t, handled) @@ -413,7 +413,7 @@ func TestHandleIPCEvents_WindowOpened_Good(t *testing.T) { // Open a window — this should trigger ActionWindowOpened // which HandleIPCEvents should convert to a WS event result, handled, err := c.PERFORM(window.TaskOpenWindow{ - Options: []window.WindowOption{window.WithName("test")}, + Window: &window.Window{Name: "test"}, }) require.NoError(t, err) assert.True(t, handled) diff --git a/pkg/mcp/tools_layout.go b/pkg/mcp/tools_layout.go index 18066d33..1719ce5d 100644 --- a/pkg/mcp/tools_layout.go +++ b/pkg/mcp/tools_layout.go @@ -136,6 +136,43 @@ func (s *Subsystem) layoutSnap(_ context.Context, _ *mcp.CallToolRequest, input return nil, LayoutSnapOutput{Success: true}, nil } +// --- layout_stack --- + +type LayoutStackInput struct { + Windows []string `json:"windows,omitempty"` + OffsetX int `json:"offsetX"` + OffsetY int `json:"offsetY"` +} +type LayoutStackOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) layoutStack(_ context.Context, _ *mcp.CallToolRequest, input LayoutStackInput) (*mcp.CallToolResult, LayoutStackOutput, error) { + _, _, err := s.core.PERFORM(window.TaskStackWindows{Windows: input.Windows, OffsetX: input.OffsetX, OffsetY: input.OffsetY}) + if err != nil { + return nil, LayoutStackOutput{}, err + } + return nil, LayoutStackOutput{Success: true}, nil +} + +// --- layout_workflow --- + +type LayoutWorkflowInput struct { + Workflow string `json:"workflow"` + Windows []string `json:"windows,omitempty"` +} +type LayoutWorkflowOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) layoutWorkflow(_ context.Context, _ *mcp.CallToolRequest, input LayoutWorkflowInput) (*mcp.CallToolResult, LayoutWorkflowOutput, error) { + _, _, err := s.core.PERFORM(window.TaskApplyWorkflow{Workflow: input.Workflow, Windows: input.Windows}) + if err != nil { + return nil, LayoutWorkflowOutput{}, err + } + return nil, LayoutWorkflowOutput{Success: true}, nil +} + // --- Registration --- func (s *Subsystem) registerLayoutTools(server *mcp.Server) { @@ -146,4 +183,6 @@ func (s *Subsystem) registerLayoutTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{Name: "layout_get", Description: "Get a specific layout by name"}, s.layoutGet) mcp.AddTool(server, &mcp.Tool{Name: "layout_tile", Description: "Tile windows in a grid arrangement"}, s.layoutTile) mcp.AddTool(server, &mcp.Tool{Name: "layout_snap", Description: "Snap a window to a screen edge or corner"}, s.layoutSnap) + mcp.AddTool(server, &mcp.Tool{Name: "layout_stack", Description: "Stack windows in a cascade pattern"}, s.layoutStack) + mcp.AddTool(server, &mcp.Tool{Name: "layout_workflow", Description: "Apply a preset workflow layout"}, s.layoutWorkflow) } diff --git a/pkg/mcp/tools_screen.go b/pkg/mcp/tools_screen.go index a89e879d..7f86e7e9 100644 --- a/pkg/mcp/tools_screen.go +++ b/pkg/mcp/tools_screen.go @@ -6,6 +6,7 @@ import ( coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/gui/pkg/screen" + "forge.lthn.ai/core/gui/pkg/window" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -109,6 +110,34 @@ func (s *Subsystem) screenWorkAreas(_ context.Context, _ *mcp.CallToolRequest, _ return nil, ScreenWorkAreasOutput{WorkAreas: areas}, nil } +// --- screen_for_window --- + +type ScreenForWindowInput struct { + Name string `json:"name"` +} +type ScreenForWindowOutput struct { + Screen *screen.Screen `json:"screen"` +} + +func (s *Subsystem) screenForWindow(_ context.Context, _ *mcp.CallToolRequest, input ScreenForWindowInput) (*mcp.CallToolResult, ScreenForWindowOutput, error) { + result, _, err := s.core.QUERY(window.QueryWindowByName{Name: input.Name}) + if err != nil { + return nil, ScreenForWindowOutput{}, err + } + info, _ := result.(*window.WindowInfo) + if info == nil { + return nil, ScreenForWindowOutput{}, nil + } + centerX := info.X + info.Width/2 + centerY := info.Y + info.Height/2 + screenResult, _, err := s.core.QUERY(screen.QueryAtPoint{X: centerX, Y: centerY}) + if err != nil { + return nil, ScreenForWindowOutput{}, err + } + scr, _ := screenResult.(*screen.Screen) + return nil, ScreenForWindowOutput{Screen: scr}, nil +} + // --- Registration --- func (s *Subsystem) registerScreenTools(server *mcp.Server) { @@ -117,4 +146,5 @@ func (s *Subsystem) registerScreenTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{Name: "screen_primary", Description: "Get the primary screen"}, s.screenPrimary) mcp.AddTool(server, &mcp.Tool{Name: "screen_at_point", Description: "Get the screen at a specific point"}, s.screenAtPoint) mcp.AddTool(server, &mcp.Tool{Name: "screen_work_areas", Description: "Get work areas for all screens"}, s.screenWorkAreas) + mcp.AddTool(server, &mcp.Tool{Name: "screen_for_window", Description: "Get the screen containing a window"}, s.screenForWindow) } diff --git a/pkg/mcp/tools_window.go b/pkg/mcp/tools_window.go index e10c3490..b735ed06 100644 --- a/pkg/mcp/tools_window.go +++ b/pkg/mcp/tools_window.go @@ -89,22 +89,17 @@ type WindowCreateOutput struct { } func (s *Subsystem) windowCreate(_ context.Context, _ *mcp.CallToolRequest, input WindowCreateInput) (*mcp.CallToolResult, WindowCreateOutput, error) { - options := []window.WindowOption{ - window.WithName(input.Name), - } - if input.Title != "" { - options = append(options, window.WithTitle(input.Title)) - } - if input.URL != "" { - options = append(options, window.WithURL(input.URL)) - } - if input.Width > 0 || input.Height > 0 { - options = append(options, window.WithSize(input.Width, input.Height)) - } - if input.X != 0 || input.Y != 0 { - options = append(options, window.WithPosition(input.X, input.Y)) - } - result, _, err := s.core.PERFORM(window.TaskOpenWindow{Options: options}) + result, _, err := s.core.PERFORM(window.TaskOpenWindow{ + Window: &window.Window{ + Name: input.Name, + Title: input.Title, + URL: input.URL, + Width: input.Width, + Height: input.Height, + X: input.X, + Y: input.Y, + }, + }) if err != nil { return nil, WindowCreateOutput{}, err } @@ -281,6 +276,27 @@ func (s *Subsystem) windowTitle(_ context.Context, _ *mcp.CallToolRequest, input return nil, WindowTitleOutput{Success: true}, nil } +// --- window_title_get --- + +type WindowTitleGetInput struct { + Name string `json:"name"` +} +type WindowTitleGetOutput struct { + Title string `json:"title"` +} + +func (s *Subsystem) windowTitleGet(_ context.Context, _ *mcp.CallToolRequest, input WindowTitleGetInput) (*mcp.CallToolResult, WindowTitleGetOutput, error) { + result, _, err := s.core.QUERY(window.QueryWindowByName{Name: input.Name}) + if err != nil { + return nil, WindowTitleGetOutput{}, err + } + info, _ := result.(*window.WindowInfo) + if info == nil { + return nil, WindowTitleGetOutput{}, nil + } + return nil, WindowTitleGetOutput{Title: info.Title}, nil +} + // --- window_visibility --- type WindowVisibilityInput struct { @@ -299,6 +315,47 @@ func (s *Subsystem) windowVisibility(_ context.Context, _ *mcp.CallToolRequest, return nil, WindowVisibilityOutput{Success: true}, nil } +// --- window_always_on_top --- + +type WindowAlwaysOnTopInput struct { + Name string `json:"name"` + AlwaysOnTop bool `json:"alwaysOnTop"` +} +type WindowAlwaysOnTopOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) windowAlwaysOnTop(_ context.Context, _ *mcp.CallToolRequest, input WindowAlwaysOnTopInput) (*mcp.CallToolResult, WindowAlwaysOnTopOutput, error) { + _, _, err := s.core.PERFORM(window.TaskSetAlwaysOnTop{Name: input.Name, AlwaysOnTop: input.AlwaysOnTop}) + if err != nil { + return nil, WindowAlwaysOnTopOutput{}, err + } + return nil, WindowAlwaysOnTopOutput{Success: true}, nil +} + +// --- window_background_colour --- + +type WindowBackgroundColourInput struct { + Name string `json:"name"` + Red uint8 `json:"red"` + Green uint8 `json:"green"` + Blue uint8 `json:"blue"` + Alpha uint8 `json:"alpha"` +} +type WindowBackgroundColourOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) windowBackgroundColour(_ context.Context, _ *mcp.CallToolRequest, input WindowBackgroundColourInput) (*mcp.CallToolResult, WindowBackgroundColourOutput, error) { + _, _, err := s.core.PERFORM(window.TaskSetBackgroundColour{ + Name: input.Name, Red: input.Red, Green: input.Green, Blue: input.Blue, Alpha: input.Alpha, + }) + if err != nil { + return nil, WindowBackgroundColourOutput{}, err + } + return nil, WindowBackgroundColourOutput{Success: true}, nil +} + // --- window_fullscreen --- type WindowFullscreenInput struct { @@ -333,6 +390,9 @@ func (s *Subsystem) registerWindowTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{Name: "window_restore", Description: "Restore a maximised or minimised window"}, s.windowRestore) mcp.AddTool(server, &mcp.Tool{Name: "window_focus", Description: "Bring a window to the front"}, s.windowFocus) mcp.AddTool(server, &mcp.Tool{Name: "window_title", Description: "Set the title of a window"}, s.windowTitle) + mcp.AddTool(server, &mcp.Tool{Name: "window_title_get", Description: "Get the title of a window"}, s.windowTitleGet) mcp.AddTool(server, &mcp.Tool{Name: "window_visibility", Description: "Show or hide a window"}, s.windowVisibility) + mcp.AddTool(server, &mcp.Tool{Name: "window_always_on_top", Description: "Pin a window above others"}, s.windowAlwaysOnTop) + mcp.AddTool(server, &mcp.Tool{Name: "window_background_colour", Description: "Set a window background colour"}, s.windowBackgroundColour) mcp.AddTool(server, &mcp.Tool{Name: "window_fullscreen", Description: "Set a window to fullscreen mode"}, s.windowFullscreen) } diff --git a/pkg/systray/menu.go b/pkg/systray/menu.go index df2bdaa5..848ee1ac 100644 --- a/pkg/systray/menu.go +++ b/pkg/systray/menu.go @@ -8,14 +8,14 @@ func (m *Manager) SetMenu(items []TrayMenuItem) error { if m.tray == nil { return fmt.Errorf("tray not initialised") } - menu := m.buildMenu(items) + menu := m.platform.NewMenu() + m.buildMenu(menu, items) m.tray.SetMenu(menu) return nil } // buildMenu recursively builds a PlatformMenu from TrayMenuItem descriptors. -func (m *Manager) buildMenu(items []TrayMenuItem) PlatformMenu { - menu := m.platform.NewMenu() +func (m *Manager) buildMenu(menu PlatformMenu, items []TrayMenuItem) { for _, item := range items { if item.Type == "separator" { menu.AddSeparator() @@ -45,7 +45,6 @@ func (m *Manager) buildMenu(items []TrayMenuItem) PlatformMenu { }) } } - return menu } // RegisterCallback registers a callback for a menu action ID. diff --git a/pkg/systray/tray_test.go b/pkg/systray/tray_test.go index 68b7feb2..6de6d392 100644 --- a/pkg/systray/tray_test.go +++ b/pkg/systray/tray_test.go @@ -87,23 +87,26 @@ func TestManager_GetInfo_Good(t *testing.T) { func TestManager_Build_Submenu_Recursive_Good(t *testing.T) { m, p := newTestManager() - items := []MenuItem{ + require.NoError(t, m.Setup("Core", "Core")) + + items := []TrayMenuItem{ { Label: "Parent", - Children: []MenuItem{ + Submenu: []TrayMenuItem{ {Label: "Child 1"}, {Label: "Child 2"}, }, }, } - menu := m.Build(items) - assert.NotNil(t, menu) + require.NoError(t, m.SetMenu(items)) require.Len(t, p.menus, 1) - require.Len(t, p.menus[0].items, 1) - assert.Equal(t, "Parent", p.menus[0].items[0]) - require.Len(t, p.menus[0].subs, 1) - require.Len(t, p.menus[0].subs[0].items, 2) - assert.Equal(t, "Child 1", p.menus[0].subs[0].items[0]) - assert.Equal(t, "Child 2", p.menus[0].subs[0].items[1]) + + menu := p.menus[0] + require.Len(t, menu.items, 1) + assert.Equal(t, "Parent", menu.items[0]) + require.Len(t, menu.subs, 1) + require.Len(t, menu.subs[0].items, 2) + assert.Equal(t, "Child 1", menu.subs[0].items[0]) + assert.Equal(t, "Child 2", menu.subs[0].items[1]) } diff --git a/pkg/window/messages.go b/pkg/window/messages.go index ece680a9..a14d4645 100644 --- a/pkg/window/messages.go +++ b/pkg/window/messages.go @@ -17,7 +17,10 @@ type QueryWindowByName struct{ Name string } type QueryConfig struct{} -type TaskOpenWindow struct{ Options []WindowOption } +type TaskOpenWindow struct { + Window *Window + Options []WindowOption +} type TaskCloseWindow struct{ Name string } @@ -44,6 +47,19 @@ type TaskSetTitle struct { Title string } +type TaskSetAlwaysOnTop struct { + Name string + AlwaysOnTop bool +} + +type TaskSetBackgroundColour struct { + Name string + Red uint8 + Green uint8 + Blue uint8 + Alpha uint8 +} + type TaskSetVisibility struct { Name string Visible bool @@ -69,11 +85,22 @@ type TaskTileWindows struct { Windows []string // window names; empty = all } +type TaskStackWindows struct { + Windows []string // window names; empty = all + OffsetX int + OffsetY int +} + type TaskSnapWindow struct { Name string // window name Position string // "left", "right", "top", "bottom", "top-left", "top-right", "bottom-left", "bottom-right", "center" } +type TaskApplyWorkflow struct { + Workflow string + Windows []string // window names; empty = all +} + type TaskSaveConfig struct{ Config map[string]any } type ActionWindowOpened struct{ Name string } diff --git a/pkg/window/mock_platform.go b/pkg/window/mock_platform.go index 1d3176c4..762f9d4a 100644 --- a/pkg/window/mock_platform.go +++ b/pkg/window/mock_platform.go @@ -33,6 +33,7 @@ type MockWindow struct { width, height, x, y int maximised, focused bool visible, alwaysOnTop bool + backgroundColour [4]uint8 closed bool eventHandlers []func(WindowEvent) fileDropHandlers []func(paths []string, targetID string) @@ -47,7 +48,7 @@ func (w *MockWindow) IsFocused() bool { return w.focused } func (w *MockWindow) SetTitle(title string) { w.title = title } func (w *MockWindow) SetPosition(x, y int) { w.x = x; w.y = y } func (w *MockWindow) SetSize(width, height int) { w.width = width; w.height = height } -func (w *MockWindow) SetBackgroundColour(r, g, b, a uint8) {} +func (w *MockWindow) SetBackgroundColour(r, g, b, a uint8) { w.backgroundColour = [4]uint8{r, g, b, a} } func (w *MockWindow) SetVisibility(visible bool) { w.visible = visible } func (w *MockWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop } func (w *MockWindow) Maximise() { w.maximised = true } diff --git a/pkg/window/mock_test.go b/pkg/window/mock_test.go index 0babb6a4..a932756f 100644 --- a/pkg/window/mock_test.go +++ b/pkg/window/mock_test.go @@ -31,6 +31,7 @@ type mockWindow struct { width, height, x, y int maximised, focused bool visible, alwaysOnTop bool + backgroundColour [4]uint8 closed bool minimised bool fullscreened bool @@ -47,7 +48,7 @@ func (w *mockWindow) IsFocused() bool { return w.focused } func (w *mockWindow) SetTitle(title string) { w.title = title } func (w *mockWindow) SetPosition(x, y int) { w.x = x; w.y = y } func (w *mockWindow) SetSize(width, height int) { w.width = width; w.height = height } -func (w *mockWindow) SetBackgroundColour(r, g, b, a uint8) {} +func (w *mockWindow) SetBackgroundColour(r, g, b, a uint8) { w.backgroundColour = [4]uint8{r, g, b, a} } func (w *mockWindow) SetVisibility(visible bool) { w.visible = visible } func (w *mockWindow) SetAlwaysOnTop(alwaysOnTop bool) { w.alwaysOnTop = alwaysOnTop } func (w *mockWindow) Maximise() { w.maximised = true } diff --git a/pkg/window/service.go b/pkg/window/service.go index a8f28842..1ae5d8aa 100644 --- a/pkg/window/service.go +++ b/pkg/window/service.go @@ -125,6 +125,10 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { return nil, true, s.taskRestore(t.Name) case TaskSetTitle: return nil, true, s.taskSetTitle(t.Name, t.Title) + case TaskSetAlwaysOnTop: + return nil, true, s.taskSetAlwaysOnTop(t.Name, t.AlwaysOnTop) + case TaskSetBackgroundColour: + return nil, true, s.taskSetBackgroundColour(t.Name, t.Red, t.Green, t.Blue, t.Alpha) case TaskSetVisibility: return nil, true, s.taskSetVisibility(t.Name, t.Visible) case TaskFullscreen: @@ -138,42 +142,60 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { return nil, true, nil case TaskTileWindows: return nil, true, s.taskTileWindows(t.Mode, t.Windows) + case TaskStackWindows: + return nil, true, s.taskStackWindows(t.Windows, t.OffsetX, t.OffsetY) case TaskSnapWindow: return nil, true, s.taskSnapWindow(t.Name, t.Position) + case TaskApplyWorkflow: + return nil, true, s.taskApplyWorkflow(t.Workflow, t.Windows) default: return nil, false, nil } } -func (s *Service) primaryScreenSize() (int, int) { +func (s *Service) primaryScreenArea() (int, int, int, int) { + const fallbackX = 0 + const fallbackY = 0 const fallbackWidth = 1920 const fallbackHeight = 1080 result, handled, err := s.Core().QUERY(screen.QueryPrimary{}) if err != nil || !handled { - return fallbackWidth, fallbackHeight + return fallbackX, fallbackY, fallbackWidth, fallbackHeight } primary, ok := result.(*screen.Screen) if !ok || primary == nil { - return fallbackWidth, fallbackHeight + return fallbackX, fallbackY, fallbackWidth, fallbackHeight } + x := primary.WorkArea.X + y := primary.WorkArea.Y width := primary.WorkArea.Width height := primary.WorkArea.Height if width <= 0 || height <= 0 { + x = primary.Bounds.X + y = primary.Bounds.Y width = primary.Bounds.Width height = primary.Bounds.Height } if width <= 0 || height <= 0 { - return fallbackWidth, fallbackHeight + return fallbackX, fallbackY, fallbackWidth, fallbackHeight } - return width, height + return x, y, width, height } func (s *Service) taskOpenWindow(t TaskOpenWindow) (any, bool, error) { - pw, err := s.manager.Open(t.Options...) + var ( + pw PlatformWindow + err error + ) + if t.Window != nil { + pw, err = s.manager.Create(t.Window) + } else { + pw, err = s.manager.Open(t.Options...) + } if err != nil { return nil, true, err } @@ -302,6 +324,24 @@ func (s *Service) taskSetTitle(name, title string) error { return nil } +func (s *Service) taskSetAlwaysOnTop(name string, alwaysOnTop bool) error { + pw, ok := s.manager.Get(name) + if !ok { + return fmt.Errorf("window not found: %s", name) + } + pw.SetAlwaysOnTop(alwaysOnTop) + return nil +} + +func (s *Service) taskSetBackgroundColour(name string, red, green, blue, alpha uint8) error { + pw, ok := s.manager.Get(name) + if !ok { + return fmt.Errorf("window not found: %s", name) + } + pw.SetBackgroundColour(red, green, blue, alpha) + return nil +} + func (s *Service) taskSetVisibility(name string, visible bool) error { pw, ok := s.manager.Get(name) if !ok { @@ -350,7 +390,10 @@ func (s *Service) taskRestoreLayout(name string) error { pw.SetSize(state.Width, state.Height) if state.Maximized { pw.Maximise() + } else { + pw.Restore() } + s.manager.State().CaptureState(pw) } return nil } @@ -371,8 +414,16 @@ func (s *Service) taskTileWindows(mode string, names []string) error { if len(names) == 0 { names = s.manager.List() } - screenWidth, screenHeight := s.primaryScreenSize() - return s.manager.TileWindows(tm, names, screenWidth, screenHeight) + originX, originY, screenWidth, screenHeight := s.primaryScreenArea() + return s.manager.TileWindows(tm, names, screenWidth, screenHeight, originX, originY) +} + +func (s *Service) taskStackWindows(names []string, offsetX, offsetY int) error { + if len(names) == 0 { + names = s.manager.List() + } + originX, originY, _, _ := s.primaryScreenArea() + return s.manager.StackWindows(names, offsetX, offsetY, originX, originY) } var snapPosMap = map[string]SnapPosition{ @@ -388,8 +439,27 @@ func (s *Service) taskSnapWindow(name, position string) error { if !ok { return fmt.Errorf("unknown snap position: %s", position) } - screenWidth, screenHeight := s.primaryScreenSize() - return s.manager.SnapWindow(name, pos, screenWidth, screenHeight) + originX, originY, screenWidth, screenHeight := s.primaryScreenArea() + return s.manager.SnapWindow(name, pos, screenWidth, screenHeight, originX, originY) +} + +var workflowLayoutMap = map[string]WorkflowLayout{ + "coding": WorkflowCoding, + "debugging": WorkflowDebugging, + "presenting": WorkflowPresenting, + "side-by-side": WorkflowSideBySide, +} + +func (s *Service) taskApplyWorkflow(workflow string, names []string) error { + layout, ok := workflowLayoutMap[workflow] + if !ok { + return fmt.Errorf("unknown workflow layout: %s", workflow) + } + if len(names) == 0 { + names = s.manager.List() + } + originX, originY, screenWidth, screenHeight := s.primaryScreenArea() + return s.manager.ApplyWorkflow(layout, names, screenWidth, screenHeight, originX, originY) } // Manager returns the underlying window Manager for direct access. diff --git a/pkg/window/service_screen_test.go b/pkg/window/service_screen_test.go index 1541dea3..117d70cf 100644 --- a/pkg/window/service_screen_test.go +++ b/pkg/window/service_screen_test.go @@ -97,3 +97,38 @@ func TestTaskSnapWindow_UsesPrimaryScreenSize(t *testing.T) { assert.Equal(t, 1000, info.Width) assert.Equal(t, 1000, info.Height) } + +func TestTaskTileWindows_UsesPrimaryWorkAreaOrigin(t *testing.T) { + _, c := newTestWindowServiceWithScreen(t, []screen.Screen{ + { + ID: "1", Name: "Primary", IsPrimary: true, + Bounds: screen.Rect{X: 0, Y: 0, Width: 2000, Height: 1000}, + WorkArea: screen.Rect{X: 100, Y: 50, Width: 2000, Height: 1000}, + }, + }) + + _, _, err := c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("left"), WithSize(400, 400)}}) + require.NoError(t, err) + _, _, err = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("right"), WithSize(400, 400)}}) + require.NoError(t, err) + + _, handled, err := c.PERFORM(TaskTileWindows{Mode: "left-right", Windows: []string{"left", "right"}}) + require.NoError(t, err) + assert.True(t, handled) + + result, _, err := c.QUERY(QueryWindowByName{Name: "left"}) + require.NoError(t, err) + left := result.(*WindowInfo) + assert.Equal(t, 100, left.X) + assert.Equal(t, 50, left.Y) + assert.Equal(t, 1000, left.Width) + assert.Equal(t, 1000, left.Height) + + result, _, err = c.QUERY(QueryWindowByName{Name: "right"}) + require.NoError(t, err) + right := result.(*WindowInfo) + assert.Equal(t, 1100, right.X) + assert.Equal(t, 50, right.Y) + assert.Equal(t, 1000, right.Width) + assert.Equal(t, 1000, right.Height) +} diff --git a/pkg/window/service_test.go b/pkg/window/service_test.go index 57fb9f48..6cdfc1a6 100644 --- a/pkg/window/service_test.go +++ b/pkg/window/service_test.go @@ -31,7 +31,7 @@ func TestRegister_Good(t *testing.T) { func TestTaskOpenWindow_Good(t *testing.T) { _, c := newTestWindowService(t) result, handled, err := c.PERFORM(TaskOpenWindow{ - Options: []WindowOption{WithName("test"), WithURL("/")}, + Window: &Window{Name: "test", URL: "/"}, }) require.NoError(t, err) assert.True(t, handled) @@ -39,6 +39,17 @@ func TestTaskOpenWindow_Good(t *testing.T) { assert.Equal(t, "test", info.Name) } +func TestTaskOpenWindow_OptionsFallback_Good(t *testing.T) { + _, c := newTestWindowService(t) + result, handled, err := c.PERFORM(TaskOpenWindow{ + Options: []WindowOption{WithName("test-fallback"), WithURL("/")}, + }) + require.NoError(t, err) + assert.True(t, handled) + info := result.(WindowInfo) + assert.Equal(t, "test-fallback", info.Name) +} + func TestTaskOpenWindow_Bad(t *testing.T) { // No window service registered — PERFORM returns handled=false c, err := core.New(core.WithServiceLock()) @@ -274,6 +285,54 @@ func TestTaskSetTitle_Bad(t *testing.T) { assert.Error(t, err) } +// --- TaskSetAlwaysOnTop --- + +func TestTaskSetAlwaysOnTop_Good(t *testing.T) { + svc, c := newTestWindowService(t) + _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}}) + + _, handled, err := c.PERFORM(TaskSetAlwaysOnTop{Name: "test", AlwaysOnTop: true}) + require.NoError(t, err) + assert.True(t, handled) + + pw, ok := svc.Manager().Get("test") + require.True(t, ok) + mw := pw.(*mockWindow) + assert.True(t, mw.alwaysOnTop) +} + +func TestTaskSetAlwaysOnTop_Bad(t *testing.T) { + _, c := newTestWindowService(t) + _, handled, err := c.PERFORM(TaskSetAlwaysOnTop{Name: "nonexistent", AlwaysOnTop: true}) + assert.True(t, handled) + assert.Error(t, err) +} + +// --- TaskSetBackgroundColour --- + +func TestTaskSetBackgroundColour_Good(t *testing.T) { + svc, c := newTestWindowService(t) + _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}}) + + _, handled, err := c.PERFORM(TaskSetBackgroundColour{ + Name: "test", Red: 10, Green: 20, Blue: 30, Alpha: 40, + }) + require.NoError(t, err) + assert.True(t, handled) + + pw, ok := svc.Manager().Get("test") + require.True(t, ok) + mw := pw.(*mockWindow) + assert.Equal(t, [4]uint8{10, 20, 30, 40}, mw.backgroundColour) +} + +func TestTaskSetBackgroundColour_Bad(t *testing.T) { + _, c := newTestWindowService(t) + _, handled, err := c.PERFORM(TaskSetBackgroundColour{Name: "nonexistent", Red: 1, Green: 2, Blue: 3, Alpha: 4}) + assert.True(t, handled) + assert.Error(t, err) +} + // --- TaskSetVisibility --- func TestTaskSetVisibility_Good(t *testing.T) { @@ -401,6 +460,16 @@ func TestTaskRestoreLayout_Good(t *testing.T) { x2, y2 := pw2.Position() assert.Equal(t, 0, x2) assert.Equal(t, 0, y2) + + editorState, ok := svc.Manager().State().GetState("editor") + require.True(t, ok) + assert.Equal(t, 0, editorState.X) + assert.Equal(t, 0, editorState.Y) + + terminalState, ok := svc.Manager().State().GetState("terminal") + require.True(t, ok) + assert.Equal(t, 0, terminalState.X) + assert.Equal(t, 0, terminalState.Y) } func TestTaskRestoreLayout_Bad(t *testing.T) { @@ -409,3 +478,45 @@ func TestTaskRestoreLayout_Bad(t *testing.T) { assert.True(t, handled) assert.Error(t, err) } + +// --- TaskStackWindows --- + +func TestTaskStackWindows_Good(t *testing.T) { + svc, c := newTestWindowService(t) + _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("s1"), WithSize(800, 600)}}) + _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("s2"), WithSize(800, 600)}}) + + _, handled, err := c.PERFORM(TaskStackWindows{Windows: []string{"s1", "s2"}, OffsetX: 25, OffsetY: 35}) + require.NoError(t, err) + assert.True(t, handled) + + pw, ok := svc.Manager().Get("s2") + require.True(t, ok) + x, y := pw.Position() + assert.Equal(t, 25, x) + assert.Equal(t, 35, y) +} + +// --- TaskApplyWorkflow --- + +func TestTaskApplyWorkflow_Good(t *testing.T) { + svc, c := newTestWindowService(t) + _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("editor"), WithSize(800, 600)}}) + _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("terminal"), WithSize(800, 600)}}) + + _, handled, err := c.PERFORM(TaskApplyWorkflow{Workflow: "side-by-side"}) + require.NoError(t, err) + assert.True(t, handled) + + editor, ok := svc.Manager().Get("editor") + require.True(t, ok) + x, y := editor.Position() + assert.Equal(t, 0, x) + assert.Equal(t, 0, y) + + terminal, ok := svc.Manager().Get("terminal") + require.True(t, ok) + x, y = terminal.Position() + assert.Equal(t, 960, x) + assert.Equal(t, 0, y) +} diff --git a/pkg/window/tiling.go b/pkg/window/tiling.go index e6ee4300..6137e89f 100644 --- a/pkg/window/tiling.go +++ b/pkg/window/tiling.go @@ -44,6 +44,16 @@ const ( SnapCenter ) +var snapPositionNames = map[SnapPosition]string{ + SnapLeft: "left", SnapRight: "right", + SnapTop: "top", SnapBottom: "bottom", + SnapTopLeft: "top-left", SnapTopRight: "top-right", + SnapBottomLeft: "bottom-left", SnapBottomRight: "bottom-right", + SnapCenter: "center", +} + +func (p SnapPosition) String() string { return snapPositionNames[p] } + // WorkflowLayout is a predefined arrangement for common tasks. type WorkflowLayout int @@ -61,8 +71,26 @@ var workflowNames = map[WorkflowLayout]string{ func (w WorkflowLayout) String() string { return workflowNames[w] } +func layoutOrigin(origin []int) (int, int) { + if len(origin) == 0 { + return 0, 0 + } + if len(origin) == 1 { + return origin[0], 0 + } + return origin[0], origin[1] +} + +func (m *Manager) captureState(pw PlatformWindow) { + if m.state == nil || pw == nil { + return + } + m.state.CaptureState(pw) +} + // TileWindows arranges the named windows in the given mode across the screen area. -func (m *Manager) TileWindows(mode TileMode, names []string, screenW, screenH int) error { +func (m *Manager) TileWindows(mode TileMode, names []string, screenW, screenH int, origin ...int) error { + originX, originY := layoutOrigin(origin) windows := make([]PlatformWindow, 0, len(names)) for _, name := range names { pw, ok := m.Get(name) @@ -81,8 +109,9 @@ func (m *Manager) TileWindows(mode TileMode, names []string, screenW, screenH in case TileModeLeftRight: w := screenW / len(windows) for i, pw := range windows { - pw.SetPosition(i*w, 0) + pw.SetPosition(originX+i*w, originY) pw.SetSize(w, screenH) + m.captureState(pw) } case TileModeGrid: cols := 2 @@ -95,55 +124,65 @@ func (m *Manager) TileWindows(mode TileMode, names []string, screenW, screenH in col := i % cols rows := (len(windows) + cols - 1) / cols cellH := screenH / rows - pw.SetPosition(col*cellW, row*cellH) + pw.SetPosition(originX+col*cellW, originY+row*cellH) pw.SetSize(cellW, cellH) + m.captureState(pw) } case TileModeLeftHalf: for _, pw := range windows { - pw.SetPosition(0, 0) + pw.SetPosition(originX, originY) pw.SetSize(halfW, screenH) + m.captureState(pw) } case TileModeRightHalf: for _, pw := range windows { - pw.SetPosition(halfW, 0) + pw.SetPosition(originX+halfW, originY) pw.SetSize(halfW, screenH) + m.captureState(pw) } case TileModeTopHalf: for _, pw := range windows { - pw.SetPosition(0, 0) + pw.SetPosition(originX, originY) pw.SetSize(screenW, halfH) + m.captureState(pw) } case TileModeBottomHalf: for _, pw := range windows { - pw.SetPosition(0, halfH) + pw.SetPosition(originX, originY+halfH) pw.SetSize(screenW, halfH) + m.captureState(pw) } case TileModeTopLeft: for _, pw := range windows { - pw.SetPosition(0, 0) + pw.SetPosition(originX, originY) pw.SetSize(halfW, halfH) + m.captureState(pw) } case TileModeTopRight: for _, pw := range windows { - pw.SetPosition(halfW, 0) + pw.SetPosition(originX+halfW, originY) pw.SetSize(halfW, halfH) + m.captureState(pw) } case TileModeBottomLeft: for _, pw := range windows { - pw.SetPosition(0, halfH) + pw.SetPosition(originX, originY+halfH) pw.SetSize(halfW, halfH) + m.captureState(pw) } case TileModeBottomRight: for _, pw := range windows { - pw.SetPosition(halfW, halfH) + pw.SetPosition(originX+halfW, originY+halfH) pw.SetSize(halfW, halfH) + m.captureState(pw) } } return nil } // SnapWindow snaps a window to a screen edge/corner/centre. -func (m *Manager) SnapWindow(name string, pos SnapPosition, screenW, screenH int) error { +func (m *Manager) SnapWindow(name string, pos SnapPosition, screenW, screenH int, origin ...int) error { + originX, originY := layoutOrigin(origin) pw, ok := m.Get(name) if !ok { return coreerr.E("window.Manager.SnapWindow", "window not found: "+name, nil) @@ -153,50 +192,54 @@ func (m *Manager) SnapWindow(name string, pos SnapPosition, screenW, screenH int switch pos { case SnapLeft: - pw.SetPosition(0, 0) + pw.SetPosition(originX, originY) pw.SetSize(halfW, screenH) case SnapRight: - pw.SetPosition(halfW, 0) + pw.SetPosition(originX+halfW, originY) pw.SetSize(halfW, screenH) case SnapTop: - pw.SetPosition(0, 0) + pw.SetPosition(originX, originY) pw.SetSize(screenW, halfH) case SnapBottom: - pw.SetPosition(0, halfH) + pw.SetPosition(originX, originY+halfH) pw.SetSize(screenW, halfH) case SnapTopLeft: - pw.SetPosition(0, 0) + pw.SetPosition(originX, originY) pw.SetSize(halfW, halfH) case SnapTopRight: - pw.SetPosition(halfW, 0) + pw.SetPosition(originX+halfW, originY) pw.SetSize(halfW, halfH) case SnapBottomLeft: - pw.SetPosition(0, halfH) + pw.SetPosition(originX, originY+halfH) pw.SetSize(halfW, halfH) case SnapBottomRight: - pw.SetPosition(halfW, halfH) + pw.SetPosition(originX+halfW, originY+halfH) pw.SetSize(halfW, halfH) case SnapCenter: cw, ch := pw.Size() - pw.SetPosition((screenW-cw)/2, (screenH-ch)/2) + pw.SetPosition(originX+(screenW-cw)/2, originY+(screenH-ch)/2) } + m.captureState(pw) return nil } // StackWindows cascades windows with an offset. -func (m *Manager) StackWindows(names []string, offsetX, offsetY int) error { +func (m *Manager) StackWindows(names []string, offsetX, offsetY int, origin ...int) error { + originX, originY := layoutOrigin(origin) for i, name := range names { pw, ok := m.Get(name) if !ok { return coreerr.E("window.Manager.StackWindows", "window not found: "+name, nil) } - pw.SetPosition(i*offsetX, i*offsetY) + pw.SetPosition(originX+i*offsetX, originY+i*offsetY) + m.captureState(pw) } return nil } // ApplyWorkflow arranges windows in a predefined workflow layout. -func (m *Manager) ApplyWorkflow(workflow WorkflowLayout, names []string, screenW, screenH int) error { +func (m *Manager) ApplyWorkflow(workflow WorkflowLayout, names []string, screenW, screenH int, origin ...int) error { + originX, originY := layoutOrigin(origin) if len(names) == 0 { return coreerr.E("window.Manager.ApplyWorkflow", "no windows for workflow", nil) } @@ -206,36 +249,41 @@ func (m *Manager) ApplyWorkflow(workflow WorkflowLayout, names []string, screenW // 70/30 split — main editor + terminal mainW := screenW * 70 / 100 if pw, ok := m.Get(names[0]); ok { - pw.SetPosition(0, 0) + pw.SetPosition(originX, originY) pw.SetSize(mainW, screenH) + m.captureState(pw) } if len(names) > 1 { if pw, ok := m.Get(names[1]); ok { - pw.SetPosition(mainW, 0) + pw.SetPosition(originX+mainW, originY) pw.SetSize(screenW-mainW, screenH) + m.captureState(pw) } } case WorkflowDebugging: // 60/40 split mainW := screenW * 60 / 100 if pw, ok := m.Get(names[0]); ok { - pw.SetPosition(0, 0) + pw.SetPosition(originX, originY) pw.SetSize(mainW, screenH) + m.captureState(pw) } if len(names) > 1 { if pw, ok := m.Get(names[1]); ok { - pw.SetPosition(mainW, 0) + pw.SetPosition(originX+mainW, originY) pw.SetSize(screenW-mainW, screenH) + m.captureState(pw) } } case WorkflowPresenting: // Maximise first window if pw, ok := m.Get(names[0]); ok { - pw.SetPosition(0, 0) + pw.SetPosition(originX, originY) pw.SetSize(screenW, screenH) + m.captureState(pw) } case WorkflowSideBySide: - return m.TileWindows(TileModeLeftRight, names, screenW, screenH) + return m.TileWindows(TileModeLeftRight, names, screenW, screenH, originX, originY) } return nil } diff --git a/stubs/wails/go.mod b/stubs/wails/go.mod new file mode 100644 index 00000000..7dcb8325 --- /dev/null +++ b/stubs/wails/go.mod @@ -0,0 +1,3 @@ +module github.com/wailsapp/wails/v3 + +go 1.26.0 diff --git a/stubs/wails/pkg/application/application.go b/stubs/wails/pkg/application/application.go new file mode 100644 index 00000000..211611c8 --- /dev/null +++ b/stubs/wails/pkg/application/application.go @@ -0,0 +1,363 @@ +package application + +import ( + "sync" + + "github.com/wailsapp/wails/v3/pkg/events" +) + +// Context mirrors the callback context type exposed by Wails. +type Context struct{} + +// Logger is a minimal logger surface used by the GUI packages. +type Logger struct{} + +func (l Logger) Info(message string, args ...any) {} + +// RGBA stores a colour with alpha. +type RGBA struct { + Red, Green, Blue, Alpha uint8 +} + +// NewRGBA constructs an RGBA value. +func NewRGBA(red, green, blue, alpha uint8) RGBA { + return RGBA{Red: red, Green: green, Blue: blue, Alpha: alpha} +} + +// MenuRole identifies a platform menu role. +type MenuRole int + +const ( + AppMenu MenuRole = iota + FileMenu + EditMenu + ViewMenu + WindowMenu + HelpMenu +) + +// MenuItem is a minimal menu item implementation. +type MenuItem struct { + Label string + Accelerator string + Tooltip string + Checked bool + Enabled bool + onClick func(*Context) +} + +func (mi *MenuItem) SetAccelerator(accel string) { mi.Accelerator = accel } +func (mi *MenuItem) SetTooltip(text string) { mi.Tooltip = text } +func (mi *MenuItem) SetChecked(checked bool) { mi.Checked = checked } +func (mi *MenuItem) SetEnabled(enabled bool) { mi.Enabled = enabled } +func (mi *MenuItem) OnClick(fn func(*Context)) { mi.onClick = fn } + +// Menu is a minimal menu tree used by the GUI wrappers. +type Menu struct { + Items []*MenuItem +} + +func NewMenu() *Menu { return &Menu{} } + +func (m *Menu) Add(label string) *MenuItem { + item := &MenuItem{Label: label, Enabled: true} + m.Items = append(m.Items, item) + return item +} + +func (m *Menu) AddSeparator() { + m.Items = append(m.Items, &MenuItem{Label: "---"}) +} + +func (m *Menu) AddSubmenu(label string) *Menu { + submenu := &Menu{} + m.Items = append(m.Items, &MenuItem{Label: label}) + return submenu +} + +func (m *Menu) AddRole(role MenuRole) { + m.Items = append(m.Items, &MenuItem{Label: role.String(), Enabled: true}) +} + +func (role MenuRole) String() string { + switch role { + case AppMenu: + return "app" + case FileMenu: + return "file" + case EditMenu: + return "edit" + case ViewMenu: + return "view" + case WindowMenu: + return "window" + case HelpMenu: + return "help" + default: + return "unknown" + } +} + +// MenuManager owns the application menu. +type MenuManager struct { + applicationMenu *Menu +} + +func (m *MenuManager) SetApplicationMenu(menu *Menu) { m.applicationMenu = menu } + +// SystemTray represents a tray instance. +type SystemTray struct { + icon []byte + templateIcon []byte + tooltip string + label string + menu *Menu + attachedWindow *WebviewWindow +} + +func (t *SystemTray) SetIcon(data []byte) { t.icon = append([]byte(nil), data...) } +func (t *SystemTray) SetTemplateIcon(data []byte) { t.templateIcon = append([]byte(nil), data...) } +func (t *SystemTray) SetTooltip(text string) { t.tooltip = text } +func (t *SystemTray) SetLabel(text string) { t.label = text } +func (t *SystemTray) SetMenu(menu *Menu) { t.menu = menu } +func (t *SystemTray) AttachWindow(w *WebviewWindow) { + t.attachedWindow = w +} + +// SystemTrayManager creates tray instances. +type SystemTrayManager struct{} + +func (m *SystemTrayManager) New() *SystemTray { return &SystemTray{} } + +// WindowEventContext carries drag-and-drop details for a window event. +type WindowEventContext struct { + droppedFiles []string + dropDetails *DropTargetDetails +} + +func (c *WindowEventContext) DroppedFiles() []string { + return append([]string(nil), c.droppedFiles...) +} + +func (c *WindowEventContext) DropTargetDetails() *DropTargetDetails { + if c.dropDetails == nil { + return nil + } + details := *c.dropDetails + return &details +} + +// DropTargetDetails mirrors the fields consumed by the GUI wrappers. +type DropTargetDetails struct { + ElementID string +} + +// WindowEvent mirrors the event object passed to window callbacks. +type WindowEvent struct { + ctx *WindowEventContext +} + +func (e *WindowEvent) Context() *WindowEventContext { + if e.ctx == nil { + e.ctx = &WindowEventContext{} + } + return e.ctx +} + +// WebviewWindowOptions configures a window instance. +type WebviewWindowOptions struct { + Name string + Title string + URL string + Width, Height int + X, Y int + MinWidth, MinHeight int + MaxWidth, MaxHeight int + Frameless bool + Hidden bool + AlwaysOnTop bool + DisableResize bool + EnableFileDrop bool + BackgroundColour RGBA +} + +// WebviewWindow is a lightweight, in-memory window implementation. +type WebviewWindow struct { + mu sync.RWMutex + opts WebviewWindowOptions + title string + x, y int + width, height int + maximised bool + focused bool + visible bool + alwaysOnTop bool + fullscreen bool + closed bool + eventHandlers map[events.WindowEventType][]func(*WindowEvent) +} + +func newWebviewWindow(options WebviewWindowOptions) *WebviewWindow { + return &WebviewWindow{ + opts: options, + title: options.Title, + x: options.X, + y: options.Y, + width: options.Width, + height: options.Height, + visible: !options.Hidden, + alwaysOnTop: options.AlwaysOnTop, + eventHandlers: make(map[events.WindowEventType][]func(*WindowEvent)), + } +} + +func (w *WebviewWindow) Name() string { return w.opts.Name } +func (w *WebviewWindow) Title() string { + w.mu.RLock() + defer w.mu.RUnlock() + return w.title +} +func (w *WebviewWindow) Position() (int, int) { + w.mu.RLock() + defer w.mu.RUnlock() + return w.x, w.y +} +func (w *WebviewWindow) Size() (int, int) { + w.mu.RLock() + defer w.mu.RUnlock() + return w.width, w.height +} +func (w *WebviewWindow) IsMaximised() bool { + w.mu.RLock() + defer w.mu.RUnlock() + return w.maximised +} +func (w *WebviewWindow) IsFocused() bool { + w.mu.RLock() + defer w.mu.RUnlock() + return w.focused +} + +func (w *WebviewWindow) SetTitle(title string) { + w.mu.Lock() + w.title = title + w.mu.Unlock() +} + +func (w *WebviewWindow) SetPosition(x, y int) { + w.mu.Lock() + w.x = x + w.y = y + w.mu.Unlock() +} + +func (w *WebviewWindow) SetSize(width, height int) { + w.mu.Lock() + w.width = width + w.height = height + w.mu.Unlock() +} + +func (w *WebviewWindow) SetBackgroundColour(colour RGBA) {} + +func (w *WebviewWindow) SetAlwaysOnTop(alwaysOnTop bool) { + w.mu.Lock() + w.alwaysOnTop = alwaysOnTop + w.mu.Unlock() +} + +func (w *WebviewWindow) Maximise() { + w.mu.Lock() + w.maximised = true + w.mu.Unlock() +} + +func (w *WebviewWindow) Restore() { + w.mu.Lock() + w.maximised = false + w.fullscreen = false + w.mu.Unlock() +} + +func (w *WebviewWindow) Minimise() {} + +func (w *WebviewWindow) Focus() { + w.mu.Lock() + w.focused = true + w.mu.Unlock() +} + +func (w *WebviewWindow) Close() { + w.mu.Lock() + w.closed = true + w.mu.Unlock() +} + +func (w *WebviewWindow) Show() { + w.mu.Lock() + w.visible = true + w.mu.Unlock() +} + +func (w *WebviewWindow) Hide() { + w.mu.Lock() + w.visible = false + w.mu.Unlock() +} + +func (w *WebviewWindow) Fullscreen() { + w.mu.Lock() + w.fullscreen = true + w.mu.Unlock() +} + +func (w *WebviewWindow) UnFullscreen() { + w.mu.Lock() + w.fullscreen = false + w.mu.Unlock() +} + +func (w *WebviewWindow) OnWindowEvent(eventType events.WindowEventType, callback func(event *WindowEvent)) func() { + w.mu.Lock() + w.eventHandlers[eventType] = append(w.eventHandlers[eventType], callback) + w.mu.Unlock() + return func() {} +} + +// WindowManager manages in-memory windows. +type WindowManager struct { + mu sync.RWMutex + windows []*WebviewWindow +} + +func (wm *WindowManager) NewWithOptions(options WebviewWindowOptions) *WebviewWindow { + window := newWebviewWindow(options) + wm.mu.Lock() + wm.windows = append(wm.windows, window) + wm.mu.Unlock() + return window +} + +func (wm *WindowManager) GetAll() []any { + wm.mu.RLock() + defer wm.mu.RUnlock() + out := make([]any, 0, len(wm.windows)) + for _, window := range wm.windows { + out = append(out, window) + } + return out +} + +// App is the top-level application object used by the GUI packages. +type App struct { + Logger Logger + Window WindowManager + Menu MenuManager + SystemTray SystemTrayManager +} + +func (a *App) Quit() {} + +func (a *App) NewMenu() *Menu { + return NewMenu() +} diff --git a/stubs/wails/pkg/events/events.go b/stubs/wails/pkg/events/events.go new file mode 100644 index 00000000..3f3204d6 --- /dev/null +++ b/stubs/wails/pkg/events/events.go @@ -0,0 +1,30 @@ +package events + +// WindowEventType identifies a window event emitted by the application layer. +type WindowEventType int + +const ( + WindowFocus WindowEventType = iota + WindowLostFocus + WindowDidMove + WindowDidResize + WindowClosing + WindowFilesDropped +) + +// Common matches the event namespace used by the real Wails package. +var Common = struct { + WindowFocus WindowEventType + WindowLostFocus WindowEventType + WindowDidMove WindowEventType + WindowDidResize WindowEventType + WindowClosing WindowEventType + WindowFilesDropped WindowEventType +}{ + WindowFocus: WindowFocus, + WindowLostFocus: WindowLostFocus, + WindowDidMove: WindowDidMove, + WindowDidResize: WindowDidResize, + WindowClosing: WindowClosing, + WindowFilesDropped: WindowFilesDropped, +} From 13a493f57d46945535d8800b854d5a0cd2a9d1db Mon Sep 17 00:00:00 2001 From: Snider Date: Tue, 31 Mar 2026 12:18:41 +0100 Subject: [PATCH 08/12] =?UTF-8?q?refactor(ax):=20AX=20compliance=20sweep?= =?UTF-8?q?=20=E2=80=94=20comments,=20error=20handling,=20test=20names?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix window/service.go: replace 3 fmt.Errorf calls with coreerr.E() (removes implicit fmt dependency) - Add usage-example comments to all bare Register() functions across 10 packages - Remove redundant prose comments (Options/Service/Register/OnStartup/HandleIPCEvents boilerplate) - Add Result-type comments to message types in contextmenu, keybinding, notification packages - Fix test naming to TestFilename_Function_{Good,Bad,Ugly} pattern in display_test, window_test, persistence_test, service_screen_test - Convert New() and CreateWindowOptions doc comments to usage-example style Co-Authored-By: Virgil --- pkg/browser/register.go | 2 ++ pkg/browser/service.go | 2 -- pkg/clipboard/service.go | 7 ++----- pkg/contextmenu/messages.go | 9 +++++---- pkg/contextmenu/register.go | 2 ++ pkg/display/display.go | 26 ++++++++++++-------------- pkg/display/display_test.go | 30 ++++++++++++++---------------- pkg/dock/register.go | 2 ++ pkg/environment/service.go | 10 +++------- pkg/keybinding/messages.go | 5 +++++ pkg/keybinding/register.go | 2 ++ pkg/lifecycle/register.go | 2 ++ pkg/mcp/subsystem.go | 8 +++----- pkg/menu/register.go | 2 ++ pkg/notification/messages.go | 4 ++++ pkg/screen/service.go | 7 ++----- pkg/systray/register.go | 2 ++ pkg/webview/service.go | 7 +++---- pkg/window/persistence_test.go | 2 +- pkg/window/register.go | 2 ++ pkg/window/service.go | 6 +++--- pkg/window/service_screen_test.go | 6 +++--- pkg/window/window_test.go | 2 +- 23 files changed, 77 insertions(+), 70 deletions(-) diff --git a/pkg/browser/register.go b/pkg/browser/register.go index 204686a4..bb9dffb0 100644 --- a/pkg/browser/register.go +++ b/pkg/browser/register.go @@ -2,6 +2,8 @@ package browser import "forge.lthn.ai/core/go/pkg/core" +// Register(p) binds the browser service to a Core instance. +// core.WithService(browser.Register(wailsBrowser)) 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 13000b99..7d05721e 100644 --- a/pkg/browser/service.go +++ b/pkg/browser/service.go @@ -22,8 +22,6 @@ func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { return nil } -// --- Task Handlers --- - func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { switch t := t.(type) { case TaskOpenURL: diff --git a/pkg/clipboard/service.go b/pkg/clipboard/service.go index 799c24f5..ee47af6e 100644 --- a/pkg/clipboard/service.go +++ b/pkg/clipboard/service.go @@ -7,16 +7,15 @@ import ( "forge.lthn.ai/core/go/pkg/core" ) -// Options holds configuration for the clipboard service. type Options struct{} -// Service is a core.Service managing clipboard operations via IPC. type Service struct { *core.ServiceRuntime[Options] platform Platform } -// Register creates a factory closure that captures the Platform adapter. +// Register(p) binds the clipboard service to a Core instance. +// c.WithService(clipboard.Register(wailsClipboard)) func Register(p Platform) func(*core.Core) (any, error) { return func(c *core.Core) (any, error) { return &Service{ @@ -26,14 +25,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 } diff --git a/pkg/contextmenu/messages.go b/pkg/contextmenu/messages.go index c5f131f8..32acb5e9 100644 --- a/pkg/contextmenu/messages.go +++ b/pkg/contextmenu/messages.go @@ -4,25 +4,26 @@ import "errors" var ErrorMenuNotFound = errors.New("contextmenu: menu not found") -// --- Queries --- - +// QueryGet returns a named context menu definition. 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 named context menu. Replaces if already exists. type TaskAdd struct { Name string `json:"name"` Menu ContextMenuDef `json:"menu"` } +// TaskRemove unregisters a context menu by name. Error: ErrorMenuNotFound if missing. type TaskRemove struct { Name string `json:"name"` } +// ActionItemClicked is broadcast when a context menu item is clicked. type ActionItemClicked struct { MenuName string `json:"menuName"` ActionID string `json:"actionId"` diff --git a/pkg/contextmenu/register.go b/pkg/contextmenu/register.go index f0c31003..548be983 100644 --- a/pkg/contextmenu/register.go +++ b/pkg/contextmenu/register.go @@ -2,6 +2,8 @@ package contextmenu import "forge.lthn.ai/core/go/pkg/core" +// Register(p) binds the context menu service to a Core instance. +// core.WithService(contextmenu.Register(wailsContextMenu)) func Register(p Platform) func(*core.Core) (any, error) { return func(c *core.Core) (any, error) { return &Service{ diff --git a/pkg/display/display.go b/pkg/display/display.go index e330ff09..f6cca53f 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -27,15 +27,13 @@ import ( "github.com/wailsapp/wails/v3/pkg/application" ) -// Options holds configuration for the display service. type Options struct{} // WindowInfo is an alias for window.WindowInfo (backward compatibility). type WindowInfo = window.WindowInfo -// Service manages windowing, dialogs, and other visual elements. -// It orchestrates sub-services (window, systray, menu) via IPC and bridges -// IPC actions to WebSocket events for TypeScript apps. +// Service orchestrates window, systray, and menu sub-services via IPC. +// Bridges IPC actions to WebSocket events for TypeScript apps. type Service struct { *core.ServiceRuntime[Options] wailsApp *application.App @@ -45,7 +43,8 @@ type Service struct { events *WSEventManager } -// New is the constructor for the display service. +// New returns a display Service with empty config sections. +// s, _ := display.New(); s.loadConfigFrom("/path/to/config.yaml") func New() (*Service, error) { return &Service{ configData: map[string]map[string]any{ @@ -56,8 +55,9 @@ func New() (*Service, error) { }, nil } -// Register creates a factory closure that captures the Wails app. -// Pass nil for testing without a Wails runtime. +// Register binds the display service to a Core instance. +// core.WithService(display.Register(app)) // production (Wails app) +// core.WithService(display.Register(nil)) // tests (no Wails runtime) func Register(wailsApp *application.App) func(*core.Core) (any, error) { return func(c *core.Core) (any, error) { s, err := New() @@ -70,9 +70,8 @@ func Register(wailsApp *application.App) func(*core.Core) (any, error) { } } -// OnStartup loads config and registers IPC handlers synchronously. -// CRITICAL: config handlers MUST be registered before returning — -// sub-services depend on them during their own OnStartup. +// OnStartup loads config and registers handlers before sub-services start. +// Config handlers are registered first — sub-services query them during their own OnStartup. func (s *Service) OnStartup(ctx context.Context) error { s.loadConfig() @@ -89,8 +88,7 @@ func (s *Service) OnStartup(ctx context.Context) error { return nil } -// HandleIPCEvents is auto-discovered and registered by core.WithService. -// It bridges sub-service IPC actions to WebSocket events for TS apps. +// HandleIPCEvents bridges IPC actions from sub-services to WebSocket events for TS apps. func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { switch m := msg.(type) { case core.ActionServiceStartup: @@ -749,7 +747,8 @@ func (s *Service) GetSavedWindowStates() map[string]window.WindowState { return result } -// CreateWindowOptions contains options for creating a new window. +// CreateWindowOptions specifies the initial state for a new named window. +// svc.CreateWindow(display.CreateWindowOptions{Name: "settings", URL: "/settings", Width: 800, Height: 600}) type CreateWindowOptions struct { Name string `json:"name"` Title string `json:"title,omitempty"` @@ -760,7 +759,6 @@ type CreateWindowOptions struct { Height int `json:"height,omitempty"` } -// CreateWindow creates a new window with the specified options. func (s *Service) CreateWindow(options CreateWindowOptions) (*window.WindowInfo, error) { if options.Name == "" { return nil, coreerr.E("display.CreateWindow", "window name is required", nil) diff --git a/pkg/display/display_test.go b/pkg/display/display_test.go index acb9c68a..4b10798f 100644 --- a/pkg/display/display_test.go +++ b/pkg/display/display_test.go @@ -46,23 +46,21 @@ func newTestConclave(t *testing.T) *core.Core { // --- Tests --- -func TestNew(t *testing.T) { - t.Run("creates service successfully", func(t *testing.T) { - service, err := New() - assert.NoError(t, err) - assert.NotNil(t, service, "New() should return a non-nil service instance") - }) - - t.Run("returns independent instances", func(t *testing.T) { - service1, err1 := New() - service2, err2 := New() - assert.NoError(t, err1) - assert.NoError(t, err2) - assert.NotSame(t, service1, service2, "New() should return different instances") - }) +func TestNew_Good(t *testing.T) { + service, err := New() + assert.NoError(t, err) + assert.NotNil(t, service) } -func TestRegisterClosure_Good(t *testing.T) { +func TestNew_Good_IndependentInstances(t *testing.T) { + service1, err1 := New() + service2, err2 := New() + assert.NoError(t, err1) + assert.NoError(t, err2) + assert.NotSame(t, service1, service2) +} + +func TestRegister_Good(t *testing.T) { factory := Register(nil) // nil wailsApp for testing assert.NotNil(t, factory) @@ -358,7 +356,7 @@ func TestGetFocusedWindow_Good(t *testing.T) { assert.Equal(t, "win-b", focused) } -func TestGetFocusedWindow_NoneSelected(t *testing.T) { +func TestGetFocusedWindow_Good_NoneSelected(t *testing.T) { c := newTestConclave(t) svc := core.MustServiceFor[*Service](c, "display") _ = svc.OpenWindow(window.WithName("win-a")) diff --git a/pkg/dock/register.go b/pkg/dock/register.go index 96ec94d0..1d9c3cd8 100644 --- a/pkg/dock/register.go +++ b/pkg/dock/register.go @@ -2,6 +2,8 @@ package dock import "forge.lthn.ai/core/go/pkg/core" +// Register(p) binds the dock service to a Core instance. +// core.WithService(dock.Register(wailsDock)) func Register(p Platform) func(*core.Core) (any, error) { return func(c *core.Core) (any, error) { return &Service{ diff --git a/pkg/environment/service.go b/pkg/environment/service.go index 7b160d91..8f4d18a5 100644 --- a/pkg/environment/service.go +++ b/pkg/environment/service.go @@ -7,17 +7,16 @@ import ( "forge.lthn.ai/core/go/pkg/core" ) -// Options holds configuration for the environment service. type Options struct{} -// Service is a core.Service providing environment queries and theme change events via IPC. type Service struct { *core.ServiceRuntime[Options] platform Platform - cancelTheme func() // cancel function for theme change listener + cancelTheme func() // returned by Platform.OnThemeChange — called on shutdown } -// Register creates a factory closure that captures the Platform adapter. +// Register(p) binds the environment service to a Core instance. +// core.WithService(environment.Register(wailsEnvironment)) func Register(p Platform) func(*core.Core) (any, error) { return func(c *core.Core) (any, error) { return &Service{ @@ -27,7 +26,6 @@ func Register(p Platform) func(*core.Core) (any, error) { } } -// OnStartup registers IPC handlers and the theme change listener. func (s *Service) OnStartup(ctx context.Context) error { s.Core().RegisterQuery(s.handleQuery) s.Core().RegisterTask(s.handleTask) @@ -39,7 +37,6 @@ func (s *Service) OnStartup(ctx context.Context) error { return nil } -// OnShutdown cancels the theme change listener. func (s *Service) OnShutdown(ctx context.Context) error { if s.cancelTheme != nil { s.cancelTheme() @@ -47,7 +44,6 @@ func (s *Service) OnShutdown(ctx context.Context) error { return nil } -// HandleIPCEvents is auto-discovered by core.WithService. func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { return nil } diff --git a/pkg/keybinding/messages.go b/pkg/keybinding/messages.go index a1688069..08771d2a 100644 --- a/pkg/keybinding/messages.go +++ b/pkg/keybinding/messages.go @@ -4,22 +4,27 @@ import "errors" var ErrorAlreadyRegistered = errors.New("keybinding: accelerator already registered") +// BindingInfo describes a registered global key binding. type BindingInfo struct { Accelerator string `json:"accelerator"` Description string `json:"description"` } +// QueryList returns all registered key bindings. Result: []BindingInfo type QueryList struct{} +// TaskAdd registers a global key binding. Error: ErrorAlreadyRegistered if accelerator taken. type TaskAdd struct { Accelerator string `json:"accelerator"` Description string `json:"description"` } +// TaskRemove unregisters a global key binding by accelerator. type TaskRemove struct { Accelerator string `json:"accelerator"` } +// ActionTriggered is broadcast when a registered key binding fires. type ActionTriggered struct { Accelerator string `json:"accelerator"` } diff --git a/pkg/keybinding/register.go b/pkg/keybinding/register.go index 091cbfa1..8e584841 100644 --- a/pkg/keybinding/register.go +++ b/pkg/keybinding/register.go @@ -2,6 +2,8 @@ package keybinding import "forge.lthn.ai/core/go/pkg/core" +// Register(p) binds the keybinding service to a Core instance. +// core.WithService(keybinding.Register(wailsKeybinding)) func Register(p Platform) func(*core.Core) (any, error) { return func(c *core.Core) (any, error) { return &Service{ diff --git a/pkg/lifecycle/register.go b/pkg/lifecycle/register.go index fcf43eaa..55031e01 100644 --- a/pkg/lifecycle/register.go +++ b/pkg/lifecycle/register.go @@ -2,6 +2,8 @@ package lifecycle import "forge.lthn.ai/core/go/pkg/core" +// Register(p) binds the lifecycle service to a Core instance. +// core.WithService(lifecycle.Register(wailsLifecycle)) func Register(p Platform) func(*core.Core) (any, error) { return func(c *core.Core) (any, error) { return &Service{ diff --git a/pkg/mcp/subsystem.go b/pkg/mcp/subsystem.go index b567d1bf..5322dba0 100644 --- a/pkg/mcp/subsystem.go +++ b/pkg/mcp/subsystem.go @@ -6,21 +6,19 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" ) -// Subsystem implements the MCP Subsystem interface via structural typing. -// It registers GUI tools that translate MCP tool calls to IPC messages. +// Subsystem translates MCP tool calls to Core IPC messages for GUI operations. type Subsystem struct { core *core.Core } -// New creates a display MCP subsystem backed by the given Core instance. +// New(c) creates a display MCP subsystem backed by a Core instance. +// sub := mcp.New(c); sub.RegisterTools(server) func New(c *core.Core) *Subsystem { return &Subsystem{core: c} } -// Name returns the subsystem identifier. func (s *Subsystem) Name() string { return "display" } -// RegisterTools registers all GUI tools with the MCP server. func (s *Subsystem) RegisterTools(server *mcp.Server) { s.registerWebviewTools(server) s.registerWindowTools(server) diff --git a/pkg/menu/register.go b/pkg/menu/register.go index 59dbae8b..b97e3cfe 100644 --- a/pkg/menu/register.go +++ b/pkg/menu/register.go @@ -2,6 +2,8 @@ package menu import "forge.lthn.ai/core/go/pkg/core" +// Register(p) binds the menu service to a Core instance. +// core.WithService(menu.Register(wailsMenu)) func Register(p Platform) func(*core.Core) (any, error) { return func(c *core.Core) (any, error) { return &Service{ diff --git a/pkg/notification/messages.go b/pkg/notification/messages.go index 1cc10f9c..67da238e 100644 --- a/pkg/notification/messages.go +++ b/pkg/notification/messages.go @@ -1,9 +1,13 @@ package notification +// QueryPermission returns current notification permission status. Result: PermissionStatus type QueryPermission struct{} +// TaskSend sends a native notification, falling back to dialog on failure. type TaskSend struct{ Options NotificationOptions } +// TaskRequestPermission requests notification permission from the OS. Result: bool (granted) type TaskRequestPermission struct{} +// ActionNotificationClicked is broadcast when the user clicks a notification. type ActionNotificationClicked struct{ ID string } diff --git a/pkg/screen/service.go b/pkg/screen/service.go index 29db4552..87b22ae6 100644 --- a/pkg/screen/service.go +++ b/pkg/screen/service.go @@ -7,16 +7,15 @@ import ( "forge.lthn.ai/core/go/pkg/core" ) -// Options holds configuration for the screen service. type Options struct{} -// Service is a core.Service providing screen/display queries via IPC. type Service struct { *core.ServiceRuntime[Options] platform Platform } -// Register creates a factory closure that captures the Platform adapter. +// Register(p) binds the screen service to a Core instance. +// core.WithService(screen.Register(wailsScreen)) func Register(p Platform) func(*core.Core) (any, error) { return func(c *core.Core) (any, error) { return &Service{ @@ -26,13 +25,11 @@ 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) return nil } -// HandleIPCEvents is auto-discovered by core.WithService. func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { return nil } diff --git a/pkg/systray/register.go b/pkg/systray/register.go index 055f35c1..bbae64cb 100644 --- a/pkg/systray/register.go +++ b/pkg/systray/register.go @@ -2,6 +2,8 @@ package systray import "forge.lthn.ai/core/go/pkg/core" +// Register(p) binds the systray service to a Core instance. +// core.WithService(systray.Register(wailsSystray)) func Register(p Platform) func(*core.Core) (any, error) { return func(c *core.Core) (any, error) { return &Service{ diff --git a/pkg/webview/service.go b/pkg/webview/service.go index b6e468bb..a17d0b3b 100644 --- a/pkg/webview/service.go +++ b/pkg/webview/service.go @@ -37,14 +37,12 @@ type connector interface { Close() error } -// Options holds configuration for the webview service. type Options struct { DebugURL string // Chrome debug endpoint (default: "http://localhost:9222") Timeout time.Duration // Operation timeout (default: 30s) ConsoleLimit int // Max console messages per window (default: 1000) } -// Service is a core.Service managing webview interactions via IPC. type Service struct { *core.ServiceRuntime[Options] options Options @@ -54,7 +52,9 @@ type Service struct { watcherSetup func(conn connector, windowName string) // called after connection creation } -// Register creates a factory closure with the given options. +// Register binds the webview service to a Core instance. +// core.WithService(webview.Register()) +// core.WithService(webview.Register(func(o *Options) { o.DebugURL = "http://localhost:9223" })) func Register(optionFns ...func(*Options)) func(*core.Core) (any, error) { o := Options{ DebugURL: "http://localhost:9222", @@ -154,7 +154,6 @@ func (s *Service) defaultWatcherSetup(conn connector, windowName string) { }) } -// OnStartup registers IPC handlers. func (s *Service) OnStartup(_ context.Context) error { s.Core().RegisterQuery(s.handleQuery) s.Core().RegisterTask(s.handleTask) diff --git a/pkg/window/persistence_test.go b/pkg/window/persistence_test.go index ba7eaeee..eed02de2 100644 --- a/pkg/window/persistence_test.go +++ b/pkg/window/persistence_test.go @@ -112,7 +112,7 @@ func TestStateManager_ApplyState_Good(t *testing.T) { assert.Equal(t, 500, w.Height) } -func TestStateManager_ApplyState_NoState(t *testing.T) { +func TestStateManager_ApplyState_Good_NoState(t *testing.T) { sm := NewStateManagerWithDir(t.TempDir()) w := &Window{Name: "untouched", Width: 1280, Height: 800, X: 10, Y: 20} diff --git a/pkg/window/register.go b/pkg/window/register.go index 850b57a9..8adb088c 100644 --- a/pkg/window/register.go +++ b/pkg/window/register.go @@ -2,6 +2,8 @@ package window import "forge.lthn.ai/core/go/pkg/core" +// Register(p) binds the window service to a Core instance. +// core.WithService(window.Register(window.NewWailsPlatform(app))) 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 1869461b..fbcbbbc5 100644 --- a/pkg/window/service.go +++ b/pkg/window/service.go @@ -327,7 +327,7 @@ func (s *Service) taskSetTitle(name, title string) error { 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) + return coreerr.E("window.taskSetAlwaysOnTop", "window not found: "+name, nil) } pw.SetAlwaysOnTop(alwaysOnTop) return nil @@ -336,7 +336,7 @@ func (s *Service) taskSetAlwaysOnTop(name string, alwaysOnTop bool) error { 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) + return coreerr.E("window.taskSetBackgroundColour", "window not found: "+name, nil) } pw.SetBackgroundColour(red, green, blue, alpha) return nil @@ -453,7 +453,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.taskApplyWorkflow", "unknown workflow layout: "+workflow, nil) } if len(names) == 0 { names = s.manager.List() diff --git a/pkg/window/service_screen_test.go b/pkg/window/service_screen_test.go index 117d70cf..ce1e1669 100644 --- a/pkg/window/service_screen_test.go +++ b/pkg/window/service_screen_test.go @@ -40,7 +40,7 @@ func newTestWindowServiceWithScreen(t *testing.T, screens []screen.Screen) (*Ser return svc, c } -func TestTaskTileWindows_UsesPrimaryScreenSize(t *testing.T) { +func TestTaskTileWindows_Good_UsesPrimaryScreenSize(t *testing.T) { _, c := newTestWindowServiceWithScreen(t, []screen.Screen{ { ID: "1", Name: "Primary", IsPrimary: true, @@ -73,7 +73,7 @@ func TestTaskTileWindows_UsesPrimaryScreenSize(t *testing.T) { assert.Equal(t, 1000, right.Height) } -func TestTaskSnapWindow_UsesPrimaryScreenSize(t *testing.T) { +func TestTaskSnapWindow_Good_UsesPrimaryScreenSize(t *testing.T) { _, c := newTestWindowServiceWithScreen(t, []screen.Screen{ { ID: "1", Name: "Primary", IsPrimary: true, @@ -98,7 +98,7 @@ func TestTaskSnapWindow_UsesPrimaryScreenSize(t *testing.T) { assert.Equal(t, 1000, info.Height) } -func TestTaskTileWindows_UsesPrimaryWorkAreaOrigin(t *testing.T) { +func TestTaskTileWindows_Good_UsesPrimaryWorkAreaOrigin(t *testing.T) { _, c := newTestWindowServiceWithScreen(t, []screen.Screen{ { ID: "1", Name: "Primary", IsPrimary: true, diff --git a/pkg/window/window_test.go b/pkg/window/window_test.go index f75fe468..e7d0de4f 100644 --- a/pkg/window/window_test.go +++ b/pkg/window/window_test.go @@ -8,7 +8,7 @@ import ( "github.com/stretchr/testify/require" ) -func TestWindowDefaults(t *testing.T) { +func TestWindowDefaults_Good(t *testing.T) { w := &Window{} assert.Equal(t, "", w.Name) assert.Equal(t, 0, w.Width) From 3bcca95b5e426a0a456c01ee0d42bad088fe7cc4 Mon Sep 17 00:00:00 2001 From: Virgil Date: Tue, 31 Mar 2026 13:19:46 +0000 Subject: [PATCH 09/12] refactor(ax): align GUI code with declarative AX principles Co-Authored-By: Virgil --- pkg/display/display.go | 15 ++++-- pkg/display/display_test.go | 76 ++++++++++++++++--------------- pkg/mcp/mcp_test.go | 6 +-- pkg/mcp/subsystem.go | 11 +++-- pkg/mcp/tools_clipboard.go | 1 - pkg/mcp/tools_contextmenu.go | 1 - pkg/mcp/tools_environment.go | 1 - pkg/menu/menu.go | 3 ++ pkg/systray/tray.go | 2 + pkg/webview/service.go | 49 +++++++++++++++----- pkg/webview/service_test.go | 52 ++++++++++++++------- pkg/window/messages.go | 7 ++- pkg/window/options.go | 6 ++- pkg/window/service.go | 11 ++--- pkg/window/service_screen_test.go | 15 ++---- pkg/window/service_test.go | 70 ++++++++++++++-------------- pkg/window/window.go | 62 ++++++++++++++++--------- pkg/window/window_test.go | 70 ++++++++++++++-------------- 18 files changed, 267 insertions(+), 191 deletions(-) diff --git a/pkg/display/display.go b/pkg/display/display.go index f6cca53f..c4b51a90 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -43,9 +43,9 @@ type Service struct { events *WSEventManager } -// New returns a display Service with empty config sections. -// s, _ := display.New(); s.loadConfigFrom("/path/to/config.yaml") -func New() (*Service, error) { +// NewService returns a display Service with empty config sections. +// svc, _ := display.NewService(); _, _ = svc.CreateWindow(display.CreateWindowOptions{Name: "settings", URL: "/settings", Width: 800, Height: 600}) +func NewService() (*Service, error) { return &Service{ configData: map[string]map[string]any{ "window": {}, @@ -55,12 +55,17 @@ func New() (*Service, error) { }, nil } +// Deprecated: use NewService(). +func New() (*Service, error) { + return NewService() +} + // Register binds the display service to a Core instance. // core.WithService(display.Register(app)) // production (Wails app) // core.WithService(display.Register(nil)) // tests (no Wails runtime) func Register(wailsApp *application.App) func(*core.Core) (any, error) { return func(c *core.Core) (any, error) { - s, err := New() + s, err := NewService() if err != nil { return nil, err } @@ -584,7 +589,7 @@ func (s *Service) windowService() *window.Service { // --- Window Management (delegates via IPC) --- -// OpenWindow creates a new window via IPC. +// Deprecated: use CreateWindow(display.CreateWindowOptions{Name: "settings", URL: "/settings", Width: 800, Height: 600}). func (s *Service) OpenWindow(options ...window.WindowOption) error { spec, err := window.ApplyOptions(options...) if err != nil { diff --git a/pkg/display/display_test.go b/pkg/display/display_test.go index 4b10798f..ebc7a8ab 100644 --- a/pkg/display/display_test.go +++ b/pkg/display/display_test.go @@ -44,17 +44,23 @@ func newTestConclave(t *testing.T) *core.Core { return c } +func requireCreateWindow(t *testing.T, svc *Service, options CreateWindowOptions) { + t.Helper() + _, err := svc.CreateWindow(options) + require.NoError(t, err) +} + // --- Tests --- -func TestNew_Good(t *testing.T) { - service, err := New() +func TestNewService_Good(t *testing.T) { + service, err := NewService() assert.NoError(t, err) assert.NotNil(t, service) } -func TestNew_Good_IndependentInstances(t *testing.T) { - service1, err1 := New() - service2, err2 := New() +func TestNewService_Good_IndependentInstances(t *testing.T) { + service1, err1 := NewService() + service2, err2 := NewService() assert.NoError(t, err1) assert.NoError(t, err2) assert.NotSame(t, service1, service2) @@ -161,7 +167,7 @@ func TestServiceConclave_Bad(t *testing.T) { // --- IPC delegation tests (full conclave) --- -func TestOpenWindow_Good(t *testing.T) { +func TestOpenWindow_Compatibility_Good(t *testing.T) { c := newTestConclave(t) svc := core.MustServiceFor[*Service](c, "display") @@ -174,17 +180,16 @@ func TestOpenWindow_Good(t *testing.T) { assert.GreaterOrEqual(t, len(infos), 1) }) - t.Run("creates window with custom options", func(t *testing.T) { - err := svc.OpenWindow( - window.WithName("custom-window"), - window.WithTitle("Custom Title"), - window.WithSize(640, 480), - window.WithURL("/custom"), - ) - assert.NoError(t, err) + t.Run("creates window with declarative options", func(t *testing.T) { + info, err := svc.CreateWindow(CreateWindowOptions{ + Name: "custom-window", + Title: "Custom Title", + URL: "/custom", + Width: 640, + Height: 480, + }) + require.NoError(t, err) - result, _, _ := c.QUERY(window.QueryWindowByName{Name: "custom-window"}) - info := result.(*window.WindowInfo) assert.Equal(t, "custom-window", info.Name) }) } @@ -193,10 +198,7 @@ func TestGetWindowInfo_Good(t *testing.T) { c := newTestConclave(t) svc := core.MustServiceFor[*Service](c, "display") - _ = svc.OpenWindow( - window.WithName("test-win"), - window.WithSize(800, 600), - ) + requireCreateWindow(t, svc, CreateWindowOptions{Name: "test-win", Width: 800, Height: 600}) // Modify position via IPC _, _, _ = c.PERFORM(window.TaskSetPosition{Name: "test-win", X: 100, Y: 200}) @@ -224,8 +226,8 @@ func TestListWindowInfos_Good(t *testing.T) { c := newTestConclave(t) svc := core.MustServiceFor[*Service](c, "display") - _ = svc.OpenWindow(window.WithName("win-1")) - _ = svc.OpenWindow(window.WithName("win-2")) + requireCreateWindow(t, svc, CreateWindowOptions{Name: "win-1"}) + requireCreateWindow(t, svc, CreateWindowOptions{Name: "win-2"}) infos := svc.ListWindowInfos() assert.Len(t, infos, 2) @@ -234,7 +236,7 @@ func TestListWindowInfos_Good(t *testing.T) { func TestSetWindowPosition_Good(t *testing.T) { c := newTestConclave(t) svc := core.MustServiceFor[*Service](c, "display") - _ = svc.OpenWindow(window.WithName("pos-win")) + requireCreateWindow(t, svc, CreateWindowOptions{Name: "pos-win"}) err := svc.SetWindowPosition("pos-win", 300, 400) assert.NoError(t, err) @@ -255,7 +257,7 @@ func TestSetWindowPosition_Bad(t *testing.T) { func TestSetWindowSize_Good(t *testing.T) { c := newTestConclave(t) svc := core.MustServiceFor[*Service](c, "display") - _ = svc.OpenWindow(window.WithName("size-win")) + requireCreateWindow(t, svc, CreateWindowOptions{Name: "size-win"}) err := svc.SetWindowSize("size-win", 1024, 768) assert.NoError(t, err) @@ -268,7 +270,7 @@ func TestSetWindowSize_Good(t *testing.T) { func TestMaximizeWindow_Good(t *testing.T) { c := newTestConclave(t) svc := core.MustServiceFor[*Service](c, "display") - _ = svc.OpenWindow(window.WithName("max-win")) + requireCreateWindow(t, svc, CreateWindowOptions{Name: "max-win"}) err := svc.MaximizeWindow("max-win") assert.NoError(t, err) @@ -280,7 +282,7 @@ func TestMaximizeWindow_Good(t *testing.T) { func TestRestoreWindow_Good(t *testing.T) { c := newTestConclave(t) svc := core.MustServiceFor[*Service](c, "display") - _ = svc.OpenWindow(window.WithName("restore-win")) + requireCreateWindow(t, svc, CreateWindowOptions{Name: "restore-win"}) _ = svc.MaximizeWindow("restore-win") err := svc.RestoreWindow("restore-win") @@ -293,7 +295,7 @@ func TestRestoreWindow_Good(t *testing.T) { func TestFocusWindow_Good(t *testing.T) { c := newTestConclave(t) svc := core.MustServiceFor[*Service](c, "display") - _ = svc.OpenWindow(window.WithName("focus-win")) + requireCreateWindow(t, svc, CreateWindowOptions{Name: "focus-win"}) err := svc.FocusWindow("focus-win") assert.NoError(t, err) @@ -305,7 +307,7 @@ func TestFocusWindow_Good(t *testing.T) { func TestCloseWindow_Good(t *testing.T) { c := newTestConclave(t) svc := core.MustServiceFor[*Service](c, "display") - _ = svc.OpenWindow(window.WithName("close-win")) + requireCreateWindow(t, svc, CreateWindowOptions{Name: "close-win"}) err := svc.CloseWindow("close-win") assert.NoError(t, err) @@ -318,7 +320,7 @@ func TestCloseWindow_Good(t *testing.T) { func TestSetWindowVisibility_Good(t *testing.T) { c := newTestConclave(t) svc := core.MustServiceFor[*Service](c, "display") - _ = svc.OpenWindow(window.WithName("vis-win")) + requireCreateWindow(t, svc, CreateWindowOptions{Name: "vis-win"}) err := svc.SetWindowVisibility("vis-win", false) assert.NoError(t, err) @@ -330,7 +332,7 @@ func TestSetWindowVisibility_Good(t *testing.T) { func TestSetWindowAlwaysOnTop_Good(t *testing.T) { c := newTestConclave(t) svc := core.MustServiceFor[*Service](c, "display") - _ = svc.OpenWindow(window.WithName("ontop-win")) + requireCreateWindow(t, svc, CreateWindowOptions{Name: "ontop-win"}) err := svc.SetWindowAlwaysOnTop("ontop-win", true) assert.NoError(t, err) @@ -339,7 +341,7 @@ func TestSetWindowAlwaysOnTop_Good(t *testing.T) { func TestSetWindowTitle_Good(t *testing.T) { c := newTestConclave(t) svc := core.MustServiceFor[*Service](c, "display") - _ = svc.OpenWindow(window.WithName("title-win")) + requireCreateWindow(t, svc, CreateWindowOptions{Name: "title-win"}) err := svc.SetWindowTitle("title-win", "New Title") assert.NoError(t, err) @@ -348,8 +350,8 @@ func TestSetWindowTitle_Good(t *testing.T) { func TestGetFocusedWindow_Good(t *testing.T) { c := newTestConclave(t) svc := core.MustServiceFor[*Service](c, "display") - _ = svc.OpenWindow(window.WithName("win-a")) - _ = svc.OpenWindow(window.WithName("win-b")) + requireCreateWindow(t, svc, CreateWindowOptions{Name: "win-a"}) + requireCreateWindow(t, svc, CreateWindowOptions{Name: "win-b"}) _ = svc.FocusWindow("win-b") focused := svc.GetFocusedWindow() @@ -359,7 +361,7 @@ func TestGetFocusedWindow_Good(t *testing.T) { func TestGetFocusedWindow_Good_NoneSelected(t *testing.T) { c := newTestConclave(t) svc := core.MustServiceFor[*Service](c, "display") - _ = svc.OpenWindow(window.WithName("win-a")) + requireCreateWindow(t, svc, CreateWindowOptions{Name: "win-a"}) focused := svc.GetFocusedWindow() assert.Equal(t, "", focused) @@ -454,7 +456,7 @@ menu: show_dev_tools: false `), 0o644)) - s, _ := New() + s, _ := NewService() s.loadConfigFrom(cfgPath) // Verify configData was populated from file @@ -464,7 +466,7 @@ menu: } func TestLoadConfig_Bad_MissingFile(t *testing.T) { - s, _ := New() + s, _ := NewService() s.loadConfigFrom(filepath.Join(t.TempDir(), "nonexistent.yaml")) // Should not panic, configData stays at empty defaults @@ -477,7 +479,7 @@ func TestHandleConfigTask_Persists_Good(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "config.yaml") - s, _ := New() + s, _ := NewService() s.loadConfigFrom(cfgPath) // Creates empty config (file doesn't exist yet) // Simulate a TaskSaveConfig through the handler diff --git a/pkg/mcp/mcp_test.go b/pkg/mcp/mcp_test.go index d3a3453b..7b9d4296 100644 --- a/pkg/mcp/mcp_test.go +++ b/pkg/mcp/mcp_test.go @@ -14,13 +14,13 @@ import ( func TestSubsystem_Good_Name(t *testing.T) { c, _ := core.New(core.WithServiceLock()) - sub := New(c) + sub := NewSubsystem(c) assert.Equal(t, "display", sub.Name()) } func TestSubsystem_Good_RegisterTools(t *testing.T) { c, _ := core.New(core.WithServiceLock()) - sub := New(c) + sub := NewSubsystem(c) // RegisterTools should not panic with a real mcp.Server server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.1.0"}, nil) assert.NotPanics(t, func() { sub.RegisterTools(server) }) @@ -34,7 +34,7 @@ type mockClipPlatform struct { } func (m *mockClipPlatform) Text() (string, bool) { return m.text, m.ok } -func (m *mockClipPlatform) SetText(t string) bool { m.text = t; m.ok = t != ""; return true } +func (m *mockClipPlatform) SetText(t string) bool { m.text = t; m.ok = t != ""; return true } func TestMCP_Good_ClipboardRoundTrip(t *testing.T) { c, err := core.New( diff --git a/pkg/mcp/subsystem.go b/pkg/mcp/subsystem.go index 5322dba0..2d3be0c4 100644 --- a/pkg/mcp/subsystem.go +++ b/pkg/mcp/subsystem.go @@ -11,12 +11,17 @@ type Subsystem struct { core *core.Core } -// New(c) creates a display MCP subsystem backed by a Core instance. -// sub := mcp.New(c); sub.RegisterTools(server) -func New(c *core.Core) *Subsystem { +// NewSubsystem creates the display MCP bridge for a Core instance. +// sub := mcp.NewSubsystem(c); sub.RegisterTools(server) +func NewSubsystem(c *core.Core) *Subsystem { return &Subsystem{core: c} } +// Deprecated: use NewSubsystem(c). +func New(c *core.Core) *Subsystem { + return NewSubsystem(c) +} + func (s *Subsystem) Name() string { return "display" } func (s *Subsystem) RegisterTools(server *mcp.Server) { diff --git a/pkg/mcp/tools_clipboard.go b/pkg/mcp/tools_clipboard.go index 2291950c..82aa4358 100644 --- a/pkg/mcp/tools_clipboard.go +++ b/pkg/mcp/tools_clipboard.go @@ -6,7 +6,6 @@ import ( coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/gui/pkg/clipboard" - coreerr "forge.lthn.ai/core/go-log" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/mcp/tools_contextmenu.go b/pkg/mcp/tools_contextmenu.go index 10d0d4a8..d6da3a57 100644 --- a/pkg/mcp/tools_contextmenu.go +++ b/pkg/mcp/tools_contextmenu.go @@ -7,7 +7,6 @@ import ( coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/gui/pkg/contextmenu" - coreerr "forge.lthn.ai/core/go-log" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/mcp/tools_environment.go b/pkg/mcp/tools_environment.go index 8e86a18a..c8fc8310 100644 --- a/pkg/mcp/tools_environment.go +++ b/pkg/mcp/tools_environment.go @@ -6,7 +6,6 @@ import ( coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/gui/pkg/environment" - coreerr "forge.lthn.ai/core/go-log" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/menu/menu.go b/pkg/menu/menu.go index 26c552ec..c916ea9d 100644 --- a/pkg/menu/menu.go +++ b/pkg/menu/menu.go @@ -20,11 +20,13 @@ type Manager struct { } // NewManager creates a menu Manager. +// menu.NewManager(menu.NewWailsPlatform(app)).SetApplicationMenu([]menu.MenuItem{{Label: "File"}}) func NewManager(platform Platform) *Manager { return &Manager{platform: platform} } // Build constructs a PlatformMenu from a tree of MenuItems. +// menu.NewManager(menu.NewWailsPlatform(app)).Build([]menu.MenuItem{{Label: "File"}}) func (m *Manager) Build(items []MenuItem) PlatformMenu { menu := m.platform.NewMenu() m.buildItems(menu, items) @@ -60,6 +62,7 @@ func (m *Manager) buildItems(menu PlatformMenu, items []MenuItem) { } // SetApplicationMenu builds and sets the application menu. +// menu.NewManager(menu.NewWailsPlatform(app)).SetApplicationMenu([]menu.MenuItem{{Label: "File"}}) func (m *Manager) SetApplicationMenu(items []MenuItem) { menu := m.Build(items) m.platform.SetApplicationMenu(menu) diff --git a/pkg/systray/tray.go b/pkg/systray/tray.go index 8d2e1087..817e1990 100644 --- a/pkg/systray/tray.go +++ b/pkg/systray/tray.go @@ -21,6 +21,7 @@ type Manager struct { } // NewManager creates a systray Manager. +// systray.NewManager(systray.NewWailsPlatform(app)).Setup("Core", "Core") func NewManager(platform Platform) *Manager { return &Manager{ platform: platform, @@ -29,6 +30,7 @@ func NewManager(platform Platform) *Manager { } // Setup creates the system tray with default icon and tooltip. +// systray.NewManager(systray.NewWailsPlatform(app)).Setup("Core", "Core") func (m *Manager) Setup(tooltip, label string) error { m.tray = m.platform.NewTray() if m.tray == nil { diff --git a/pkg/webview/service.go b/pkg/webview/service.go index a17d0b3b..2b7ccbcc 100644 --- a/pkg/webview/service.go +++ b/pkg/webview/service.go @@ -43,6 +43,28 @@ type Options struct { ConsoleLimit int // Max console messages per window (default: 1000) } +func defaultOptions() Options { + return Options{ + DebugURL: "http://localhost:9222", + Timeout: 30 * time.Second, + ConsoleLimit: 1000, + } +} + +func normalizeOptions(options Options) Options { + defaults := defaultOptions() + if options.DebugURL == "" { + options.DebugURL = defaults.DebugURL + } + if options.Timeout == 0 { + options.Timeout = defaults.Timeout + } + if options.ConsoleLimit == 0 { + options.ConsoleLimit = defaults.ConsoleLimit + } + return options +} + type Service struct { *core.ServiceRuntime[Options] options Options @@ -52,18 +74,10 @@ type Service struct { watcherSetup func(conn connector, windowName string) // called after connection creation } -// Register binds the webview service to a Core instance. -// core.WithService(webview.Register()) -// core.WithService(webview.Register(func(o *Options) { o.DebugURL = "http://localhost:9223" })) -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 optionFns { - fn(&o) - } +// RegisterWithOptions binds the webview service to a Core instance using a declarative Options literal. +// core.WithService(webview.RegisterWithOptions(webview.Options{DebugURL: "http://localhost:9223", Timeout: 30 * time.Second, ConsoleLimit: 1000})) +func RegisterWithOptions(options Options) func(*core.Core) (any, error) { + o := normalizeOptions(options) return func(c *core.Core) (any, error) { svc := &Service{ ServiceRuntime: core.NewServiceRuntime[Options](c, o), @@ -76,6 +90,17 @@ func Register(optionFns ...func(*Options)) func(*core.Core) (any, error) { } } +// Deprecated: use RegisterWithOptions(webview.Options{DebugURL: "http://localhost:9223", Timeout: 30 * time.Second, ConsoleLimit: 1000}). +func Register(optionFns ...func(*Options)) func(*core.Core) (any, error) { + options := defaultOptions() + for _, fn := range optionFns { + if fn != nil { + fn(&options) + } + } + return RegisterWithOptions(options) +} + // defaultNewConn creates real go-webview connections. func defaultNewConn(options Options) func(string, string) (connector, error) { return func(debugURL, windowName string) (connector, error) { diff --git a/pkg/webview/service_test.go b/pkg/webview/service_test.go index 45a8f207..6578159a 100644 --- a/pkg/webview/service_test.go +++ b/pkg/webview/service_test.go @@ -37,21 +37,41 @@ type mockConnector struct { consoleClearCalled bool } -func (m *mockConnector) Navigate(url string) error { m.lastNavURL = url; return nil } -func (m *mockConnector) Click(sel string) error { m.lastClickSel = sel; return nil } -func (m *mockConnector) Type(sel, text string) error { m.lastTypeSel = sel; m.lastTypeText = text; return nil } -func (m *mockConnector) Hover(sel string) error { m.lastHoverSel = sel; return nil } -func (m *mockConnector) Select(sel, val string) error { m.lastSelectSel = sel; m.lastSelectVal = val; return nil } -func (m *mockConnector) Check(sel string, c bool) error { m.lastCheckSel = sel; m.lastCheckVal = c; return nil } -func (m *mockConnector) Evaluate(s string) (any, error) { return m.evalResult, nil } -func (m *mockConnector) Screenshot() ([]byte, error) { return m.screenshot, nil } -func (m *mockConnector) GetURL() (string, error) { return m.url, nil } -func (m *mockConnector) GetTitle() (string, error) { return m.title, nil } +func (m *mockConnector) Navigate(url string) error { m.lastNavURL = url; return nil } +func (m *mockConnector) Click(sel string) error { m.lastClickSel = sel; return nil } +func (m *mockConnector) Type(sel, text string) error { + m.lastTypeSel = sel + m.lastTypeText = text + return nil +} +func (m *mockConnector) Hover(sel string) error { m.lastHoverSel = sel; return nil } +func (m *mockConnector) Select(sel, val string) error { + m.lastSelectSel = sel + m.lastSelectVal = val + return nil +} +func (m *mockConnector) Check(sel string, c bool) error { + m.lastCheckSel = sel + m.lastCheckVal = c + return nil +} +func (m *mockConnector) Evaluate(s string) (any, error) { return m.evalResult, nil } +func (m *mockConnector) Screenshot() ([]byte, error) { return m.screenshot, nil } +func (m *mockConnector) GetURL() (string, error) { return m.url, nil } +func (m *mockConnector) GetTitle() (string, error) { return m.title, nil } func (m *mockConnector) GetHTML(sel string) (string, error) { return m.html, nil } -func (m *mockConnector) ClearConsole() { m.consoleClearCalled = true } -func (m *mockConnector) Close() error { m.closed = true; return nil } -func (m *mockConnector) SetViewport(w, h int) error { m.lastViewportW = w; m.lastViewportH = h; return nil } -func (m *mockConnector) UploadFile(sel string, p []string) error { m.lastUploadSel = sel; m.lastUploadPaths = p; return nil } +func (m *mockConnector) ClearConsole() { m.consoleClearCalled = true } +func (m *mockConnector) Close() error { m.closed = true; return nil } +func (m *mockConnector) SetViewport(w, h int) error { + m.lastViewportW = w + m.lastViewportH = h + return nil +} +func (m *mockConnector) UploadFile(sel string, p []string) error { + m.lastUploadSel = sel + m.lastUploadPaths = p + return nil +} func (m *mockConnector) QuerySelector(sel string) (*ElementInfo, error) { if len(m.elements) > 0 { @@ -68,7 +88,7 @@ func (m *mockConnector) GetConsole() []ConsoleMessage { return m.console } func newTestService(t *testing.T, mock *mockConnector) (*Service, *core.Core) { t.Helper() - factory := Register() + factory := RegisterWithOptions(Options{}) c, err := core.New(core.WithService(factory), core.WithServiceLock()) require.NoError(t, err) require.NoError(t, c.ServiceStartup(context.Background(), nil)) @@ -78,7 +98,7 @@ func newTestService(t *testing.T, mock *mockConnector) (*Service, *core.Core) { return svc, c } -func TestRegister_Good(t *testing.T) { +func TestRegisterWithOptions_Good(t *testing.T) { svc, _ := newTestService(t, &mockConnector{}) assert.NotNil(t, svc) } diff --git a/pkg/window/messages.go b/pkg/window/messages.go index a14d4645..ef637178 100644 --- a/pkg/window/messages.go +++ b/pkg/window/messages.go @@ -17,10 +17,9 @@ type QueryWindowByName struct{ Name string } type QueryConfig struct{} -type TaskOpenWindow struct { - Window *Window - Options []WindowOption -} +// TaskOpenWindow opens a concrete Window descriptor. +// window.TaskOpenWindow{Window: &window.Window{Name: "settings", URL: "/", Width: 800, Height: 600}} +type TaskOpenWindow struct{ Window *Window } type TaskCloseWindow struct{ Name string } diff --git a/pkg/window/options.go b/pkg/window/options.go index 38c5064e..9e9f48fd 100644 --- a/pkg/window/options.go +++ b/pkg/window/options.go @@ -1,10 +1,11 @@ // pkg/window/options.go package window -// WindowOption is a functional option applied to a Window descriptor. +// WindowOption is the compatibility layer for option-chain callers. +// Prefer a Window literal with Manager.CreateWindow. type WindowOption func(*Window) error -// ApplyOptions creates a Window and applies all options in order. +// Deprecated: use Manager.CreateWindow(Window{Name: "settings", URL: "/", Width: 800, Height: 600}). func ApplyOptions(options ...WindowOption) (*Window, error) { w := &Window{} for _, option := range options { @@ -18,6 +19,7 @@ func ApplyOptions(options ...WindowOption) (*Window, error) { return w, nil } +// Compatibility helpers for callers still using option chains. func WithName(name string) WindowOption { return func(w *Window) error { w.Name = name; return nil } } diff --git a/pkg/window/service.go b/pkg/window/service.go index fbcbbbc5..731929af 100644 --- a/pkg/window/service.go +++ b/pkg/window/service.go @@ -187,15 +187,10 @@ func (s *Service) primaryScreenArea() (int, int, int, int) { } func (s *Service) taskOpenWindow(t TaskOpenWindow) (any, bool, error) { - var ( - pw PlatformWindow - err error - ) - if t.Window != nil { - pw, err = s.manager.Create(t.Window) - } else { - pw, err = s.manager.Open(t.Options...) + if t.Window == nil { + return nil, true, coreerr.E("window.taskOpenWindow", "window descriptor is required", nil) } + pw, err := s.manager.CreateWindow(*t.Window) if err != nil { return nil, true, err } diff --git a/pkg/window/service_screen_test.go b/pkg/window/service_screen_test.go index ce1e1669..af86b81b 100644 --- a/pkg/window/service_screen_test.go +++ b/pkg/window/service_screen_test.go @@ -49,10 +49,8 @@ func TestTaskTileWindows_Good_UsesPrimaryScreenSize(t *testing.T) { }, }) - _, _, err := c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("left"), WithSize(400, 400)}}) - require.NoError(t, err) - _, _, err = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("right"), WithSize(400, 400)}}) - require.NoError(t, err) + _ = requireOpenWindow(t, c, Window{Name: "left", Width: 400, Height: 400}) + _ = requireOpenWindow(t, c, Window{Name: "right", Width: 400, Height: 400}) _, handled, err := c.PERFORM(TaskTileWindows{Mode: "left-right", Windows: []string{"left", "right"}}) require.NoError(t, err) @@ -82,8 +80,7 @@ func TestTaskSnapWindow_Good_UsesPrimaryScreenSize(t *testing.T) { }, }) - _, _, err := c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("snap"), WithSize(400, 300)}}) - require.NoError(t, err) + _ = requireOpenWindow(t, c, Window{Name: "snap", Width: 400, Height: 300}) _, handled, err := c.PERFORM(TaskSnapWindow{Name: "snap", Position: "left"}) require.NoError(t, err) @@ -107,10 +104,8 @@ func TestTaskTileWindows_Good_UsesPrimaryWorkAreaOrigin(t *testing.T) { }, }) - _, _, err := c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("left"), WithSize(400, 400)}}) - require.NoError(t, err) - _, _, err = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("right"), WithSize(400, 400)}}) - require.NoError(t, err) + _ = requireOpenWindow(t, c, Window{Name: "left", Width: 400, Height: 400}) + _ = requireOpenWindow(t, c, Window{Name: "right", Width: 400, Height: 400}) _, handled, err := c.PERFORM(TaskTileWindows{Mode: "left-right", Windows: []string{"left", "right"}}) require.NoError(t, err) diff --git a/pkg/window/service_test.go b/pkg/window/service_test.go index 6cdfc1a6..3aa33f29 100644 --- a/pkg/window/service_test.go +++ b/pkg/window/service_test.go @@ -22,6 +22,14 @@ func newTestWindowService(t *testing.T) (*Service, *core.Core) { return svc, c } +func requireOpenWindow(t *testing.T, c *core.Core, window Window) WindowInfo { + t.Helper() + result, handled, err := c.PERFORM(TaskOpenWindow{Window: &window}) + require.NoError(t, err) + require.True(t, handled) + return result.(WindowInfo) +} + func TestRegister_Good(t *testing.T) { svc, _ := newTestWindowService(t) assert.NotNil(t, svc) @@ -39,15 +47,12 @@ func TestTaskOpenWindow_Good(t *testing.T) { assert.Equal(t, "test", info.Name) } -func TestTaskOpenWindow_OptionsFallback_Good(t *testing.T) { +func TestTaskOpenWindow_Bad_MissingWindow(t *testing.T) { _, c := newTestWindowService(t) - result, handled, err := c.PERFORM(TaskOpenWindow{ - Options: []WindowOption{WithName("test-fallback"), WithURL("/")}, - }) - require.NoError(t, err) + result, handled, err := c.PERFORM(TaskOpenWindow{}) assert.True(t, handled) - info := result.(WindowInfo) - assert.Equal(t, "test-fallback", info.Name) + assert.Error(t, err) + assert.Nil(t, result) } func TestTaskOpenWindow_Bad(t *testing.T) { @@ -60,8 +65,8 @@ func TestTaskOpenWindow_Bad(t *testing.T) { func TestQueryWindowList_Good(t *testing.T) { _, c := newTestWindowService(t) - _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("a")}}) - _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("b")}}) + _ = requireOpenWindow(t, c, Window{Name: "a"}) + _ = requireOpenWindow(t, c, Window{Name: "b"}) result, handled, err := c.QUERY(QueryWindowList{}) require.NoError(t, err) @@ -72,7 +77,7 @@ func TestQueryWindowList_Good(t *testing.T) { func TestQueryWindowByName_Good(t *testing.T) { _, c := newTestWindowService(t) - _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}}) + _ = requireOpenWindow(t, c, Window{Name: "test"}) result, handled, err := c.QUERY(QueryWindowByName{Name: "test"}) require.NoError(t, err) @@ -91,7 +96,7 @@ func TestQueryWindowByName_Bad(t *testing.T) { func TestTaskCloseWindow_Good(t *testing.T) { _, c := newTestWindowService(t) - _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}}) + _ = requireOpenWindow(t, c, Window{Name: "test"}) _, handled, err := c.PERFORM(TaskCloseWindow{Name: "test"}) require.NoError(t, err) @@ -111,7 +116,7 @@ func TestTaskCloseWindow_Bad(t *testing.T) { func TestTaskSetPosition_Good(t *testing.T) { _, c := newTestWindowService(t) - _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}}) + _ = requireOpenWindow(t, c, Window{Name: "test"}) _, handled, err := c.PERFORM(TaskSetPosition{Name: "test", X: 100, Y: 200}) require.NoError(t, err) @@ -125,7 +130,7 @@ func TestTaskSetPosition_Good(t *testing.T) { func TestTaskSetSize_Good(t *testing.T) { _, c := newTestWindowService(t) - _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}}) + _ = requireOpenWindow(t, c, Window{Name: "test"}) _, handled, err := c.PERFORM(TaskSetSize{Name: "test", Width: 800, Height: 600}) require.NoError(t, err) @@ -139,7 +144,7 @@ func TestTaskSetSize_Good(t *testing.T) { func TestTaskMaximise_Good(t *testing.T) { _, c := newTestWindowService(t) - _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}}) + _ = requireOpenWindow(t, c, Window{Name: "test"}) _, handled, err := c.PERFORM(TaskMaximise{Name: "test"}) require.NoError(t, err) @@ -154,10 +159,7 @@ func TestFileDrop_Good(t *testing.T) { _, c := newTestWindowService(t) // Open a window - result, _, _ := c.PERFORM(TaskOpenWindow{ - Options: []WindowOption{WithName("drop-test")}, - }) - info := result.(WindowInfo) + info := requireOpenWindow(t, c, Window{Name: "drop-test"}) assert.Equal(t, "drop-test", info.Name) // Capture broadcast actions @@ -190,7 +192,7 @@ func TestFileDrop_Good(t *testing.T) { func TestTaskMinimise_Good(t *testing.T) { svc, c := newTestWindowService(t) - _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}}) + _ = requireOpenWindow(t, c, Window{Name: "test"}) _, handled, err := c.PERFORM(TaskMinimise{Name: "test"}) require.NoError(t, err) @@ -213,7 +215,7 @@ func TestTaskMinimise_Bad(t *testing.T) { func TestTaskFocus_Good(t *testing.T) { svc, c := newTestWindowService(t) - _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}}) + _ = requireOpenWindow(t, c, Window{Name: "test"}) _, handled, err := c.PERFORM(TaskFocus{Name: "test"}) require.NoError(t, err) @@ -236,7 +238,7 @@ func TestTaskFocus_Bad(t *testing.T) { func TestTaskRestore_Good(t *testing.T) { svc, c := newTestWindowService(t) - _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}}) + _ = requireOpenWindow(t, c, Window{Name: "test"}) // First maximise, then restore _, _, _ = c.PERFORM(TaskMaximise{Name: "test"}) @@ -267,7 +269,7 @@ func TestTaskRestore_Bad(t *testing.T) { func TestTaskSetTitle_Good(t *testing.T) { svc, c := newTestWindowService(t) - _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}}) + _ = requireOpenWindow(t, c, Window{Name: "test"}) _, handled, err := c.PERFORM(TaskSetTitle{Name: "test", Title: "New Title"}) require.NoError(t, err) @@ -289,7 +291,7 @@ func TestTaskSetTitle_Bad(t *testing.T) { func TestTaskSetAlwaysOnTop_Good(t *testing.T) { svc, c := newTestWindowService(t) - _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}}) + _ = requireOpenWindow(t, c, Window{Name: "test"}) _, handled, err := c.PERFORM(TaskSetAlwaysOnTop{Name: "test", AlwaysOnTop: true}) require.NoError(t, err) @@ -312,7 +314,7 @@ func TestTaskSetAlwaysOnTop_Bad(t *testing.T) { func TestTaskSetBackgroundColour_Good(t *testing.T) { svc, c := newTestWindowService(t) - _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}}) + _ = requireOpenWindow(t, c, Window{Name: "test"}) _, handled, err := c.PERFORM(TaskSetBackgroundColour{ Name: "test", Red: 10, Green: 20, Blue: 30, Alpha: 40, @@ -337,7 +339,7 @@ func TestTaskSetBackgroundColour_Bad(t *testing.T) { func TestTaskSetVisibility_Good(t *testing.T) { svc, c := newTestWindowService(t) - _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}}) + _ = requireOpenWindow(t, c, Window{Name: "test"}) _, handled, err := c.PERFORM(TaskSetVisibility{Name: "test", Visible: true}) require.NoError(t, err) @@ -366,7 +368,7 @@ func TestTaskSetVisibility_Bad(t *testing.T) { func TestTaskFullscreen_Good(t *testing.T) { svc, c := newTestWindowService(t) - _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("test")}}) + _ = requireOpenWindow(t, c, Window{Name: "test"}) // Enter fullscreen _, handled, err := c.PERFORM(TaskFullscreen{Name: "test", Fullscreen: true}) @@ -396,8 +398,8 @@ func TestTaskFullscreen_Bad(t *testing.T) { 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)}}) + _ = requireOpenWindow(t, c, Window{Name: "editor", Width: 960, Height: 1080, X: 0, Y: 0}) + _ = requireOpenWindow(t, c, Window{Name: "terminal", Width: 960, Height: 1080, X: 960, Y: 0}) _, handled, err := c.PERFORM(TaskSaveLayout{Name: "coding"}) require.NoError(t, err) @@ -433,8 +435,8 @@ func TestTaskSaveLayout_Bad(t *testing.T) { 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)}}) + _ = requireOpenWindow(t, c, Window{Name: "editor", Width: 800, Height: 600, X: 0, Y: 0}) + _ = requireOpenWindow(t, c, Window{Name: "terminal", Width: 800, Height: 600, X: 0, Y: 0}) // Save a layout with specific positions _, _, _ = c.PERFORM(TaskSaveLayout{Name: "coding"}) @@ -483,8 +485,8 @@ func TestTaskRestoreLayout_Bad(t *testing.T) { func TestTaskStackWindows_Good(t *testing.T) { svc, c := newTestWindowService(t) - _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("s1"), WithSize(800, 600)}}) - _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("s2"), WithSize(800, 600)}}) + _ = requireOpenWindow(t, c, Window{Name: "s1", Width: 800, Height: 600}) + _ = requireOpenWindow(t, c, Window{Name: "s2", Width: 800, Height: 600}) _, handled, err := c.PERFORM(TaskStackWindows{Windows: []string{"s1", "s2"}, OffsetX: 25, OffsetY: 35}) require.NoError(t, err) @@ -501,8 +503,8 @@ func TestTaskStackWindows_Good(t *testing.T) { func TestTaskApplyWorkflow_Good(t *testing.T) { svc, c := newTestWindowService(t) - _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("editor"), WithSize(800, 600)}}) - _, _, _ = c.PERFORM(TaskOpenWindow{Options: []WindowOption{WithName("terminal"), WithSize(800, 600)}}) + _ = requireOpenWindow(t, c, Window{Name: "editor", Width: 800, Height: 600}) + _ = requireOpenWindow(t, c, Window{Name: "terminal", Width: 800, Height: 600}) _, handled, err := c.PERFORM(TaskApplyWorkflow{Workflow: "side-by-side"}) require.NoError(t, err) diff --git a/pkg/window/window.go b/pkg/window/window.go index d6aaaf04..6334b42a 100644 --- a/pkg/window/window.go +++ b/pkg/window/window.go @@ -49,6 +49,7 @@ type Manager struct { } // NewManager creates a window Manager with the given platform backend. +// window.NewManager(window.NewWailsPlatform(app)) func NewManager(platform Platform) *Manager { return &Manager{ platform: platform, @@ -59,7 +60,7 @@ func NewManager(platform Platform) *Manager { } // NewManagerWithDir creates a window Manager with a custom config directory for state/layout persistence. -// Useful for testing or when the default config directory is not appropriate. +// window.NewManagerWithDir(window.NewMockPlatform(), t.TempDir()) func NewManagerWithDir(platform Platform, configDir string) *Manager { return &Manager{ platform: platform, @@ -81,51 +82,70 @@ func (m *Manager) SetDefaultHeight(height int) { } } -// Open creates a window using functional options, applies saved state, and tracks it. +// Deprecated: use CreateWindow(Window{Name: "settings", URL: "/settings", Width: 800, Height: 600}). func (m *Manager) Open(options ...WindowOption) (PlatformWindow, error) { w, err := ApplyOptions(options...) if err != nil { return nil, coreerr.E("window.Manager.Open", "failed to apply options", err) } - return m.Create(w) + return m.CreateWindow(*w) } -// Create creates a window from a Window descriptor. +// CreateWindow creates a window from a Window descriptor. +// window.NewManager(window.NewWailsPlatform(app)).CreateWindow(window.Window{Name: "settings", URL: "/settings", Width: 800, Height: 600}) +func (m *Manager) CreateWindow(spec Window) (PlatformWindow, error) { + _, pw, err := m.createWindow(spec) + return pw, err +} + +// Deprecated: use CreateWindow(Window{Name: "settings", URL: "/settings", Width: 800, Height: 600}). func (m *Manager) Create(w *Window) (PlatformWindow, error) { - if w.Name == "" { - w.Name = "main" + if w == nil { + return nil, coreerr.E("window.Manager.Create", "window descriptor is required", nil) } - if w.Title == "" { - w.Title = "Core" + spec, pw, err := m.createWindow(*w) + if err != nil { + return nil, err } - if w.Width == 0 { + *w = spec + return pw, nil +} + +func (m *Manager) createWindow(spec Window) (Window, PlatformWindow, error) { + if spec.Name == "" { + spec.Name = "main" + } + if spec.Title == "" { + spec.Title = "Core" + } + if spec.Width == 0 { if m.defaultWidth > 0 { - w.Width = m.defaultWidth + spec.Width = m.defaultWidth } else { - w.Width = 1280 + spec.Width = 1280 } } - if w.Height == 0 { + if spec.Height == 0 { if m.defaultHeight > 0 { - w.Height = m.defaultHeight + spec.Height = m.defaultHeight } else { - w.Height = 800 + spec.Height = 800 } } - if w.URL == "" { - w.URL = "/" + if spec.URL == "" { + spec.URL = "/" } - // Apply saved state if available - m.state.ApplyState(w) + // Apply saved state if available. + m.state.ApplyState(&spec) - pw := m.platform.CreateWindow(w.ToPlatformOptions()) + pw := m.platform.CreateWindow(spec.ToPlatformOptions()) m.mu.Lock() - m.windows[w.Name] = pw + m.windows[spec.Name] = pw m.mu.Unlock() - return pw, nil + return spec, pw, nil } // Get returns a tracked window by name. diff --git a/pkg/window/window_test.go b/pkg/window/window_test.go index e7d0de4f..94b4ef1f 100644 --- a/pkg/window/window_test.go +++ b/pkg/window/window_test.go @@ -91,39 +91,48 @@ func newTestManager() (*Manager, *mockPlatform) { return m, p } -func TestManager_Open_Good(t *testing.T) { - m, p := newTestManager() - pw, err := m.Open(WithName("test"), WithTitle("Test"), WithURL("/test"), WithSize(800, 600)) +func requireCreateWindow(t *testing.T, m *Manager, w Window) PlatformWindow { + t.Helper() + pw, err := m.CreateWindow(w) require.NoError(t, err) + return pw +} + +func TestManager_CreateWindow_Good(t *testing.T) { + m, p := newTestManager() + pw := requireCreateWindow(t, m, Window{ + Name: "test", + Title: "Test", + URL: "/test", + Width: 800, + Height: 600, + }) assert.NotNil(t, pw) assert.Equal(t, "test", pw.Name()) assert.Len(t, p.windows, 1) } -func TestManager_Open_Defaults_Good(t *testing.T) { +func TestManager_CreateWindow_Defaults_Good(t *testing.T) { m, _ := newTestManager() - pw, err := m.Open() - require.NoError(t, err) + pw := requireCreateWindow(t, m, Window{}) assert.Equal(t, "main", pw.Name()) w, h := pw.Size() assert.Equal(t, 1280, w) assert.Equal(t, 800, h) } -func TestManager_Open_CustomDefaults_Good(t *testing.T) { +func TestManager_CreateWindow_CustomDefaults_Good(t *testing.T) { m, _ := newTestManager() m.SetDefaultWidth(1440) m.SetDefaultHeight(900) - pw, err := m.Open() - require.NoError(t, err) - + pw := requireCreateWindow(t, m, Window{}) w, h := pw.Size() assert.Equal(t, 1440, w) assert.Equal(t, 900, h) } -func TestManager_Open_Bad(t *testing.T) { +func TestManager_Open_Compatibility_Bad(t *testing.T) { m, _ := newTestManager() _, err := m.Open(func(w *Window) error { return assert.AnError }) assert.Error(t, err) @@ -131,7 +140,7 @@ func TestManager_Open_Bad(t *testing.T) { func TestManager_Get_Good(t *testing.T) { m, _ := newTestManager() - _, _ = m.Open(WithName("findme")) + _ = requireCreateWindow(t, m, Window{Name: "findme"}) pw, ok := m.Get("findme") assert.True(t, ok) assert.Equal(t, "findme", pw.Name()) @@ -145,8 +154,8 @@ func TestManager_Get_Bad(t *testing.T) { func TestManager_List_Good(t *testing.T) { m, _ := newTestManager() - _, _ = m.Open(WithName("a")) - _, _ = m.Open(WithName("b")) + _ = requireCreateWindow(t, m, Window{Name: "a"}) + _ = requireCreateWindow(t, m, Window{Name: "b"}) names := m.List() assert.Len(t, names, 2) assert.Contains(t, names, "a") @@ -155,7 +164,7 @@ func TestManager_List_Good(t *testing.T) { func TestManager_Remove_Good(t *testing.T) { m, _ := newTestManager() - _, _ = m.Open(WithName("temp")) + _ = requireCreateWindow(t, m, Window{Name: "temp"}) m.Remove("temp") _, ok := m.Get("temp") assert.False(t, ok) @@ -170,8 +179,8 @@ func TestTileMode_String_Good(t *testing.T) { func TestManager_TileWindows_Good(t *testing.T) { m, _ := newTestManager() - _, _ = m.Open(WithName("a"), WithSize(800, 600)) - _, _ = m.Open(WithName("b"), WithSize(800, 600)) + _ = requireCreateWindow(t, m, Window{Name: "a", Width: 800, Height: 600}) + _ = requireCreateWindow(t, m, Window{Name: "b", Width: 800, Height: 600}) err := m.TileWindows(TileModeLeftRight, []string{"a", "b"}, 1920, 1080) require.NoError(t, err) a, _ := m.Get("a") @@ -190,7 +199,7 @@ func TestManager_TileWindows_Bad(t *testing.T) { func TestManager_SnapWindow_Good(t *testing.T) { m, _ := newTestManager() - _, _ = m.Open(WithName("snap"), WithSize(800, 600)) + _ = requireCreateWindow(t, m, Window{Name: "snap", Width: 800, Height: 600}) err := m.SnapWindow("snap", SnapLeft, 1920, 1080) require.NoError(t, err) w, _ := m.Get("snap") @@ -202,8 +211,8 @@ func TestManager_SnapWindow_Good(t *testing.T) { func TestManager_StackWindows_Good(t *testing.T) { m, _ := newTestManager() - _, _ = m.Open(WithName("s1"), WithSize(800, 600)) - _, _ = m.Open(WithName("s2"), WithSize(800, 600)) + _ = requireCreateWindow(t, m, Window{Name: "s1", Width: 800, Height: 600}) + _ = requireCreateWindow(t, m, Window{Name: "s2", Width: 800, Height: 600}) err := m.StackWindows([]string{"s1", "s2"}, 30, 30) require.NoError(t, err) s2, _ := m.Get("s2") @@ -244,10 +253,9 @@ func TestTileWindows_AllModes_Good(t *testing.T) { 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) + _ = requireCreateWindow(t, m, Window{Name: "win", Width: 800, Height: 600}) - err = m.TileWindows(tc.mode, []string{"win"}, screenW, screenH) + err := m.TileWindows(tc.mode, []string{"win"}, screenW, screenH) require.NoError(t, err) pw, ok := m.Get("win") @@ -290,10 +298,9 @@ func TestSnapWindow_AllPositions_Good(t *testing.T) { 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) + _ = requireCreateWindow(t, m, Window{Name: "snap", Width: tc.initW, Height: tc.initH}) - err = m.SnapWindow("snap", tc.pos, screenW, screenH) + err := m.SnapWindow("snap", tc.pos, screenW, screenH) require.NoError(t, err) pw, ok := m.Get("snap") @@ -313,8 +320,7 @@ 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) + _ = requireCreateWindow(t, m, Window{Name: name, Width: 800, Height: 600}) } err := m.StackWindows(names, 30, 30) @@ -369,12 +375,10 @@ func TestApplyWorkflow_AllLayouts_Good(t *testing.T) { 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) + _ = requireCreateWindow(t, m, Window{Name: "editor", Width: 800, Height: 600}) + _ = requireCreateWindow(t, m, Window{Name: "terminal", Width: 800, Height: 600}) - err = m.ApplyWorkflow(tc.workflow, []string{"editor", "terminal"}, screenW, screenH) + err := m.ApplyWorkflow(tc.workflow, []string{"editor", "terminal"}, screenW, screenH) require.NoError(t, err) pw0, ok := m.Get("editor") From d9fa59ab04df080cf53732ff68d99ccaea8acc49 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 31 Mar 2026 17:20:22 +0100 Subject: [PATCH 10/12] =?UTF-8?q?feat(stubs):=20rebuild=20Wails=20v3=20stu?= =?UTF-8?q?b=20bridge=20from=20clean=20dev=20=E2=80=94=2015=20files,=20479?= =?UTF-8?q?=20exports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebuilt from scratch on current dev (post-fleet AX passes). Stub files: - application.go (expanded App with 12 managers, WebviewWindow satisfies Window) - application_options.go (Options, Mac/Win/Linux/iOS/Android, Server, TLS, Assets) - browser_manager.go, browser_window.go (~47 no-op methods) - clipboard.go, context_menu.go, dialog.go (full dialog builder API) - environment.go, events.go (EventManager with On/Off/Emit/Reset) - keybinding.go, menuitem.go (42 Role constants) - screen.go (Rect/Point/Size geometry), services.go (generic Service[T]) - webview_window_options.go (full platform types) - window.go (Window interface ~50 methods) Wails v3 submodule at internal/wails3/ pinned to alpha 74. All 16 gui packages build and test clean. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/wails3 | 1 + stubs/wails/pkg/application/application.go | 783 ++++++++++++++---- .../pkg/application/application_options.go | 348 ++++++++ .../wails/pkg/application/browser_manager.go | 21 + stubs/wails/pkg/application/browser_window.go | 230 +++++ stubs/wails/pkg/application/clipboard.go | 65 ++ stubs/wails/pkg/application/context_menu.go | 73 ++ stubs/wails/pkg/application/dialog.go | 481 +++++++++++ stubs/wails/pkg/application/environment.go | 63 ++ stubs/wails/pkg/application/events.go | 297 +++++++ stubs/wails/pkg/application/keybinding.go | 69 ++ stubs/wails/pkg/application/menuitem.go | 379 +++++++++ stubs/wails/pkg/application/screen.go | 202 +++++ stubs/wails/pkg/application/services.go | 76 ++ .../pkg/application/webview_window_options.go | 471 +++++++++++ stubs/wails/pkg/application/window.go | 160 ++++ stubs/wails/pkg/events/events.go | 38 +- 17 files changed, 3561 insertions(+), 196 deletions(-) create mode 160000 internal/wails3 create mode 100644 stubs/wails/pkg/application/application_options.go create mode 100644 stubs/wails/pkg/application/browser_manager.go create mode 100644 stubs/wails/pkg/application/browser_window.go create mode 100644 stubs/wails/pkg/application/clipboard.go create mode 100644 stubs/wails/pkg/application/context_menu.go create mode 100644 stubs/wails/pkg/application/dialog.go create mode 100644 stubs/wails/pkg/application/environment.go create mode 100644 stubs/wails/pkg/application/events.go create mode 100644 stubs/wails/pkg/application/keybinding.go create mode 100644 stubs/wails/pkg/application/menuitem.go create mode 100644 stubs/wails/pkg/application/screen.go create mode 100644 stubs/wails/pkg/application/services.go create mode 100644 stubs/wails/pkg/application/webview_window_options.go create mode 100644 stubs/wails/pkg/application/window.go diff --git a/internal/wails3 b/internal/wails3 new file mode 160000 index 00000000..bb4fbf95 --- /dev/null +++ b/internal/wails3 @@ -0,0 +1 @@ +Subproject commit bb4fbf95744fafe5acf84e143a419bfffc2159e6 diff --git a/stubs/wails/pkg/application/application.go b/stubs/wails/pkg/application/application.go index 211611c8..60a86e5a 100644 --- a/stubs/wails/pkg/application/application.go +++ b/stubs/wails/pkg/application/application.go @@ -2,12 +2,36 @@ package application import ( "sync" + "unsafe" "github.com/wailsapp/wails/v3/pkg/events" ) // Context mirrors the callback context type exposed by Wails. -type Context struct{} +// +// item.OnClick(func(ctx *Context) { openPrefs() }) +type Context struct { + clickedMenuItem *MenuItem + contextMenuData *ContextMenuData + checked bool +} + +func newContext() *Context { return &Context{} } + +func (ctx *Context) withClickedMenuItem(item *MenuItem) *Context { + ctx.clickedMenuItem = item + return ctx +} + +func (ctx *Context) withContextMenuData(data *ContextMenuData) *Context { + ctx.contextMenuData = data + return ctx +} + +func (ctx *Context) withChecked(checked bool) *Context { + ctx.checked = checked + return ctx +} // Logger is a minimal logger surface used by the GUI packages. type Logger struct{} @@ -15,90 +39,92 @@ type Logger struct{} func (l Logger) Info(message string, args ...any) {} // RGBA stores a colour with alpha. +// +// colour := NewRGBA(255, 128, 0, 255) // opaque orange type RGBA struct { Red, Green, Blue, Alpha uint8 } // NewRGBA constructs an RGBA value. +// +// colour := NewRGBA(255, 128, 0, 255) // opaque orange func NewRGBA(red, green, blue, alpha uint8) RGBA { return RGBA{Red: red, Green: green, Blue: blue, Alpha: alpha} } -// MenuRole identifies a platform menu role. -type MenuRole int - -const ( - AppMenu MenuRole = iota - FileMenu - EditMenu - ViewMenu - WindowMenu - HelpMenu -) - -// MenuItem is a minimal menu item implementation. -type MenuItem struct { - Label string - Accelerator string - Tooltip string - Checked bool - Enabled bool - onClick func(*Context) -} - -func (mi *MenuItem) SetAccelerator(accel string) { mi.Accelerator = accel } -func (mi *MenuItem) SetTooltip(text string) { mi.Tooltip = text } -func (mi *MenuItem) SetChecked(checked bool) { mi.Checked = checked } -func (mi *MenuItem) SetEnabled(enabled bool) { mi.Enabled = enabled } -func (mi *MenuItem) OnClick(fn func(*Context)) { mi.onClick = fn } - -// Menu is a minimal menu tree used by the GUI wrappers. +// Menu is a menu tree used by the GUI wrappers. +// +// menu := NewMenu() +// menu.Add("Save").SetAccelerator("CmdOrCtrl+S").OnClick(func(ctx *Context) { save() }) type Menu struct { + label string Items []*MenuItem } +// NewMenu creates a new, empty Menu. +// +// menu := NewMenu() func NewMenu() *Menu { return &Menu{} } +// Add appends a new text item with the given label. func (m *Menu) Add(label string) *MenuItem { - item := &MenuItem{Label: label, Enabled: true} + item := NewMenuItem(label) + item.disabled = false m.Items = append(m.Items, item) return item } +// AddSeparator appends a separator item. func (m *Menu) AddSeparator() { - m.Items = append(m.Items, &MenuItem{Label: "---"}) + m.Items = append(m.Items, NewMenuItemSeparator()) } +// AddSubmenu appends a submenu item and returns the child Menu. func (m *Menu) AddSubmenu(label string) *Menu { - submenu := &Menu{} - m.Items = append(m.Items, &MenuItem{Label: label}) - return submenu + item := NewSubMenuItem(label) + m.Items = append(m.Items, item) + return item.submenu } -func (m *Menu) AddRole(role MenuRole) { - m.Items = append(m.Items, &MenuItem{Label: role.String(), Enabled: true}) +// AddRole appends a platform-role item. +func (m *Menu) AddRole(role Role) { + m.Items = append(m.Items, NewRole(role)) } -func (role MenuRole) String() string { - switch role { - case AppMenu: - return "app" - case FileMenu: - return "file" - case EditMenu: - return "edit" - case ViewMenu: - return "view" - case WindowMenu: - return "window" - case HelpMenu: - return "help" - default: - return "unknown" +// AppendItem appends an already-constructed MenuItem. +func (m *Menu) AppendItem(item *MenuItem) { + m.Items = append(m.Items, item) +} + +// Clone returns a deep copy of the menu tree. +func (m *Menu) Clone() *Menu { + cloned := &Menu{label: m.label} + for _, item := range m.Items { + cloned.Items = append(cloned.Items, item.Clone()) + } + return cloned +} + +// Destroy frees all items in the menu. +func (m *Menu) Destroy() { + for _, item := range m.Items { + item.Destroy() + } + m.Items = nil +} + +func (m *Menu) setContextData(data *ContextMenuData) { + for _, item := range m.Items { + item.contextMenuData = data + if item.submenu != nil { + item.submenu.setContextData(data) + } } } // MenuManager owns the application menu. +// +// app.Menu.SetApplicationMenu(menu) type MenuManager struct { applicationMenu *Menu } @@ -112,7 +138,7 @@ type SystemTray struct { tooltip string label string menu *Menu - attachedWindow *WebviewWindow + attachedWindow Window } func (t *SystemTray) SetIcon(data []byte) { t.icon = append([]byte(nil), data...) } @@ -120,9 +146,9 @@ func (t *SystemTray) SetTemplateIcon(data []byte) { t.templateIcon = append([]by func (t *SystemTray) SetTooltip(text string) { t.tooltip = text } func (t *SystemTray) SetLabel(text string) { t.label = text } func (t *SystemTray) SetMenu(menu *Menu) { t.menu = menu } -func (t *SystemTray) AttachWindow(w *WebviewWindow) { - t.attachedWindow = w -} + +// AttachWindow associates a window with the tray icon (shown on click). +func (t *SystemTray) AttachWindow(w Window) { t.attachedWindow = w } // SystemTrayManager creates tray instances. type SystemTrayManager struct{} @@ -164,159 +190,408 @@ func (e *WindowEvent) Context() *WindowEventContext { return e.ctx } -// WebviewWindowOptions configures a window instance. -type WebviewWindowOptions struct { - Name string - Title string - URL string - Width, Height int - X, Y int - MinWidth, MinHeight int - MaxWidth, MaxHeight int - Frameless bool - Hidden bool - AlwaysOnTop bool - DisableResize bool - EnableFileDrop bool - BackgroundColour RGBA +// WebviewWindow is a lightweight, in-memory window implementation +// that satisfies the Window interface. +type WebviewWindow struct { + mu sync.RWMutex + opts WebviewWindowOptions + windowID uint + title string + posX, posY int + sizeW, sizeH int + maximised bool + focused bool + visible bool + alwaysOnTop bool + isFullscreen bool + closed bool + zoom float64 + resizable bool + ignoreMouseEvents bool + enabled bool + eventHandlers map[events.WindowEventType][]func(*WindowEvent) } -// WebviewWindow is a lightweight, in-memory window implementation. -type WebviewWindow struct { - mu sync.RWMutex - opts WebviewWindowOptions - title string - x, y int - width, height int - maximised bool - focused bool - visible bool - alwaysOnTop bool - fullscreen bool - closed bool - eventHandlers map[events.WindowEventType][]func(*WindowEvent) +var globalWindowID uint +var globalWindowIDMu sync.Mutex + +func nextWindowID() uint { + globalWindowIDMu.Lock() + defer globalWindowIDMu.Unlock() + globalWindowID++ + return globalWindowID } func newWebviewWindow(options WebviewWindowOptions) *WebviewWindow { return &WebviewWindow{ opts: options, + windowID: nextWindowID(), title: options.Title, - x: options.X, - y: options.Y, - width: options.Width, - height: options.Height, + posX: options.X, + posY: options.Y, + sizeW: options.Width, + sizeH: options.Height, visible: !options.Hidden, alwaysOnTop: options.AlwaysOnTop, + zoom: options.Zoom, + resizable: !options.DisableResize, + enabled: true, eventHandlers: make(map[events.WindowEventType][]func(*WindowEvent)), } } +// ID returns the unique numeric identifier for the window. +func (w *WebviewWindow) ID() uint { return w.windowID } + +// Name returns the window name set in WebviewWindowOptions. func (w *WebviewWindow) Name() string { return w.opts.Name } -func (w *WebviewWindow) Title() string { + +// Show makes the window visible and returns it for chaining. +func (w *WebviewWindow) Show() Window { + w.mu.Lock() + w.visible = true + w.mu.Unlock() + return w +} + +// Hide makes the window invisible and returns it for chaining. +func (w *WebviewWindow) Hide() Window { + w.mu.Lock() + w.visible = false + w.mu.Unlock() + return w +} + +// IsVisible reports whether the window is currently visible. +func (w *WebviewWindow) IsVisible() bool { w.mu.RLock() defer w.mu.RUnlock() - return w.title -} -func (w *WebviewWindow) Position() (int, int) { - w.mu.RLock() - defer w.mu.RUnlock() - return w.x, w.y -} -func (w *WebviewWindow) Size() (int, int) { - w.mu.RLock() - defer w.mu.RUnlock() - return w.width, w.height -} -func (w *WebviewWindow) IsMaximised() bool { - w.mu.RLock() - defer w.mu.RUnlock() - return w.maximised -} -func (w *WebviewWindow) IsFocused() bool { - w.mu.RLock() - defer w.mu.RUnlock() - return w.focused -} - -func (w *WebviewWindow) SetTitle(title string) { - w.mu.Lock() - w.title = title - w.mu.Unlock() -} - -func (w *WebviewWindow) SetPosition(x, y int) { - w.mu.Lock() - w.x = x - w.y = y - w.mu.Unlock() -} - -func (w *WebviewWindow) SetSize(width, height int) { - w.mu.Lock() - w.width = width - w.height = height - w.mu.Unlock() -} - -func (w *WebviewWindow) SetBackgroundColour(colour RGBA) {} - -func (w *WebviewWindow) SetAlwaysOnTop(alwaysOnTop bool) { - w.mu.Lock() - w.alwaysOnTop = alwaysOnTop - w.mu.Unlock() -} - -func (w *WebviewWindow) Maximise() { - w.mu.Lock() - w.maximised = true - w.mu.Unlock() -} - -func (w *WebviewWindow) Restore() { - w.mu.Lock() - w.maximised = false - w.fullscreen = false - w.mu.Unlock() -} - -func (w *WebviewWindow) Minimise() {} - -func (w *WebviewWindow) Focus() { - w.mu.Lock() - w.focused = true - w.mu.Unlock() + return w.visible } +// Close marks the window as closed. func (w *WebviewWindow) Close() { w.mu.Lock() w.closed = true w.mu.Unlock() } -func (w *WebviewWindow) Show() { +// Focus marks the window as focused. +func (w *WebviewWindow) Focus() { + w.mu.Lock() + w.focused = true + w.mu.Unlock() +} + +// Run is a no-op in the stub (the real implementation enters the run loop). +func (w *WebviewWindow) Run() {} + +// Center is a no-op in the stub. +func (w *WebviewWindow) Center() {} + +// Position returns the current x/y position. +func (w *WebviewWindow) Position() (int, int) { + w.mu.RLock() + defer w.mu.RUnlock() + return w.posX, w.posY +} + +// RelativePosition returns the position relative to the screen. +func (w *WebviewWindow) RelativePosition() (int, int) { + w.mu.RLock() + defer w.mu.RUnlock() + return w.posX, w.posY +} + +// Size returns the current width and height. +func (w *WebviewWindow) Size() (int, int) { + w.mu.RLock() + defer w.mu.RUnlock() + return w.sizeW, w.sizeH +} + +// Width returns the current window width. +func (w *WebviewWindow) Width() int { + w.mu.RLock() + defer w.mu.RUnlock() + return w.sizeW +} + +// Height returns the current window height. +func (w *WebviewWindow) Height() int { + w.mu.RLock() + defer w.mu.RUnlock() + return w.sizeH +} + +// Bounds returns the window's position and size as a Rect. +func (w *WebviewWindow) Bounds() Rect { + w.mu.RLock() + defer w.mu.RUnlock() + return Rect{X: w.posX, Y: w.posY, Width: w.sizeW, Height: w.sizeH} +} + +// SetPosition sets the top-left corner position. +func (w *WebviewWindow) SetPosition(x, y int) { + w.mu.Lock() + w.posX, w.posY = x, y + w.mu.Unlock() +} + +// SetRelativePosition sets position relative to the screen and returns the window. +func (w *WebviewWindow) SetRelativePosition(x, y int) Window { + w.SetPosition(x, y) + return w +} + +// SetSize sets the window dimensions and returns the window. +func (w *WebviewWindow) SetSize(width, height int) Window { + w.mu.Lock() + w.sizeW, w.sizeH = width, height + w.mu.Unlock() + return w +} + +// SetBounds sets position and size in one call. +func (w *WebviewWindow) SetBounds(bounds Rect) { + w.mu.Lock() + w.posX, w.posY = bounds.X, bounds.Y + w.sizeW, w.sizeH = bounds.Width, bounds.Height + w.mu.Unlock() +} + +// SetMaxSize is a no-op in the stub. +func (w *WebviewWindow) SetMaxSize(maxWidth, maxHeight int) Window { return w } + +// SetMinSize is a no-op in the stub. +func (w *WebviewWindow) SetMinSize(minWidth, minHeight int) Window { return w } + +// EnableSizeConstraints is a no-op in the stub. +func (w *WebviewWindow) EnableSizeConstraints() {} + +// DisableSizeConstraints is a no-op in the stub. +func (w *WebviewWindow) DisableSizeConstraints() {} + +// Resizable reports whether the user can resize the window. +func (w *WebviewWindow) Resizable() bool { + w.mu.RLock() + defer w.mu.RUnlock() + return w.resizable +} + +// SetResizable enables or disables user resizing and returns the window. +func (w *WebviewWindow) SetResizable(b bool) Window { + w.mu.Lock() + w.resizable = b + w.mu.Unlock() + return w +} + +// Maximise maximises the window and returns it. +func (w *WebviewWindow) Maximise() Window { + w.mu.Lock() + w.maximised = true + w.mu.Unlock() + return w +} + +// UnMaximise restores from maximised state. +func (w *WebviewWindow) UnMaximise() { + w.mu.Lock() + w.maximised = false + w.mu.Unlock() +} + +// ToggleMaximise toggles between maximised and normal. +func (w *WebviewWindow) ToggleMaximise() { + w.mu.Lock() + w.maximised = !w.maximised + w.mu.Unlock() +} + +// IsMaximised reports whether the window is maximised. +func (w *WebviewWindow) IsMaximised() bool { + w.mu.RLock() + defer w.mu.RUnlock() + return w.maximised +} + +// Minimise minimises the window and returns it. +func (w *WebviewWindow) Minimise() Window { + w.mu.Lock() + w.visible = false + w.mu.Unlock() + return w +} + +// UnMinimise restores from minimised state. +func (w *WebviewWindow) UnMinimise() { w.mu.Lock() w.visible = true w.mu.Unlock() } -func (w *WebviewWindow) Hide() { +// IsMinimised always returns false in the stub. +func (w *WebviewWindow) IsMinimised() bool { return false } + +// Fullscreen enters fullscreen and returns the window. +func (w *WebviewWindow) Fullscreen() Window { w.mu.Lock() - w.visible = false - w.mu.Unlock() -} - -func (w *WebviewWindow) Fullscreen() { - w.mu.Lock() - w.fullscreen = true + w.isFullscreen = true w.mu.Unlock() + return w } +// UnFullscreen exits fullscreen. func (w *WebviewWindow) UnFullscreen() { w.mu.Lock() - w.fullscreen = false + w.isFullscreen = false w.mu.Unlock() } +// ToggleFullscreen toggles between fullscreen and normal. +func (w *WebviewWindow) ToggleFullscreen() { + w.mu.Lock() + w.isFullscreen = !w.isFullscreen + w.mu.Unlock() +} + +// IsFullscreen reports whether the window is in fullscreen mode. +func (w *WebviewWindow) IsFullscreen() bool { + w.mu.RLock() + defer w.mu.RUnlock() + return w.isFullscreen +} + +// Restore exits both fullscreen and maximised states. +func (w *WebviewWindow) Restore() { + w.mu.Lock() + w.maximised = false + w.isFullscreen = false + w.mu.Unlock() +} + +// SnapAssist is a no-op in the stub. +func (w *WebviewWindow) SnapAssist() {} + +// SetTitle updates the window title and returns the window. +func (w *WebviewWindow) SetTitle(title string) Window { + w.mu.Lock() + w.title = title + w.mu.Unlock() + return w +} + +// SetURL is a no-op in the stub. +func (w *WebviewWindow) SetURL(s string) Window { return w } + +// SetHTML is a no-op in the stub. +func (w *WebviewWindow) SetHTML(html string) Window { return w } + +// SetMinimiseButtonState is a no-op in the stub. +func (w *WebviewWindow) SetMinimiseButtonState(state ButtonState) Window { return w } + +// SetMaximiseButtonState is a no-op in the stub. +func (w *WebviewWindow) SetMaximiseButtonState(state ButtonState) Window { return w } + +// SetCloseButtonState is a no-op in the stub. +func (w *WebviewWindow) SetCloseButtonState(state ButtonState) Window { return w } + +// SetMenu is a no-op in the stub. +func (w *WebviewWindow) SetMenu(menu *Menu) {} + +// ShowMenuBar is a no-op in the stub. +func (w *WebviewWindow) ShowMenuBar() {} + +// HideMenuBar is a no-op in the stub. +func (w *WebviewWindow) HideMenuBar() {} + +// ToggleMenuBar is a no-op in the stub. +func (w *WebviewWindow) ToggleMenuBar() {} + +// SetBackgroundColour is a no-op in the stub. +func (w *WebviewWindow) SetBackgroundColour(colour RGBA) Window { return w } + +// SetAlwaysOnTop sets the always-on-top flag and returns the window. +func (w *WebviewWindow) SetAlwaysOnTop(b bool) Window { + w.mu.Lock() + w.alwaysOnTop = b + w.mu.Unlock() + return w +} + +// SetFrameless is a no-op in the stub. +func (w *WebviewWindow) SetFrameless(frameless bool) Window { return w } + +// ToggleFrameless is a no-op in the stub. +func (w *WebviewWindow) ToggleFrameless() {} + +// SetIgnoreMouseEvents sets the mouse-event passthrough flag and returns the window. +func (w *WebviewWindow) SetIgnoreMouseEvents(ignore bool) Window { + w.mu.Lock() + w.ignoreMouseEvents = ignore + w.mu.Unlock() + return w +} + +// IsIgnoreMouseEvents reports whether mouse events are being ignored. +func (w *WebviewWindow) IsIgnoreMouseEvents() bool { + w.mu.RLock() + defer w.mu.RUnlock() + return w.ignoreMouseEvents +} + +// SetContentProtection is a no-op in the stub. +func (w *WebviewWindow) SetContentProtection(protection bool) Window { return w } + +// GetZoom returns the current zoom magnification. +func (w *WebviewWindow) GetZoom() float64 { + w.mu.RLock() + defer w.mu.RUnlock() + return w.zoom +} + +// SetZoom sets the zoom magnification and returns the window. +func (w *WebviewWindow) SetZoom(magnification float64) Window { + w.mu.Lock() + w.zoom = magnification + w.mu.Unlock() + return w +} + +// Zoom is a no-op in the stub. +func (w *WebviewWindow) Zoom() {} + +// ZoomIn is a no-op in the stub. +func (w *WebviewWindow) ZoomIn() {} + +// ZoomOut is a no-op in the stub. +func (w *WebviewWindow) ZoomOut() {} + +// ZoomReset resets zoom to 1.0 and returns the window. +func (w *WebviewWindow) ZoomReset() Window { + w.mu.Lock() + w.zoom = 1.0 + w.mu.Unlock() + return w +} + +// GetBorderSizes returns zero insets in the stub. +func (w *WebviewWindow) GetBorderSizes() *LRTB { return &LRTB{} } + +// GetScreen returns nil in the stub. +func (w *WebviewWindow) GetScreen() (*Screen, error) { return nil, nil } + +// ExecJS is a no-op in the stub. +func (w *WebviewWindow) ExecJS(js string) {} + +// EmitEvent always returns false in the stub. +func (w *WebviewWindow) EmitEvent(name string, data ...any) bool { return false } + +// DispatchWailsEvent is a no-op in the stub. +func (w *WebviewWindow) DispatchWailsEvent(event *CustomEvent) {} + +// OnWindowEvent registers an event callback and returns an unsubscribe function. func (w *WebviewWindow) OnWindowEvent(eventType events.WindowEventType, callback func(event *WindowEvent)) func() { w.mu.Lock() w.eventHandlers[eventType] = append(w.eventHandlers[eventType], callback) @@ -324,40 +599,180 @@ func (w *WebviewWindow) OnWindowEvent(eventType events.WindowEventType, callback return func() {} } -// WindowManager manages in-memory windows. -type WindowManager struct { - mu sync.RWMutex - windows []*WebviewWindow +// RegisterHook is an alias for OnWindowEvent. +func (w *WebviewWindow) RegisterHook(eventType events.WindowEventType, callback func(event *WindowEvent)) func() { + return w.OnWindowEvent(eventType, callback) } +// handleDragAndDropMessage is a no-op in the stub. +func (w *WebviewWindow) handleDragAndDropMessage(filenames []string, dropTarget *DropTargetDetails) {} + +// InitiateFrontendDropProcessing is a no-op in the stub. +func (w *WebviewWindow) InitiateFrontendDropProcessing(filenames []string, x int, y int) {} + +// HandleMessage is a no-op in the stub. +func (w *WebviewWindow) HandleMessage(message string) {} + +// HandleWindowEvent is a no-op in the stub. +func (w *WebviewWindow) HandleWindowEvent(id uint) {} + +// HandleKeyEvent is a no-op in the stub. +func (w *WebviewWindow) HandleKeyEvent(acceleratorString string) {} + +// OpenContextMenu is a no-op in the stub. +func (w *WebviewWindow) OpenContextMenu(data *ContextMenuData) {} + +// AttachModal is a no-op in the stub. +func (w *WebviewWindow) AttachModal(modalWindow Window) {} + +// OpenDevTools is a no-op in the stub. +func (w *WebviewWindow) OpenDevTools() {} + +// Print always returns nil in the stub. +func (w *WebviewWindow) Print() error { return nil } + +// Flash is a no-op in the stub. +func (w *WebviewWindow) Flash(enabled bool) {} + +// IsFocused reports whether the window has focus. +func (w *WebviewWindow) IsFocused() bool { + w.mu.RLock() + defer w.mu.RUnlock() + return w.focused +} + +// NativeWindow returns nil in the stub. +func (w *WebviewWindow) NativeWindow() unsafe.Pointer { return nil } + +// SetEnabled sets the window's enabled state. +func (w *WebviewWindow) SetEnabled(enabled bool) { + w.mu.Lock() + w.enabled = enabled + w.mu.Unlock() +} + +// Reload is a no-op in the stub. +func (w *WebviewWindow) Reload() {} + +// ForceReload is a no-op in the stub. +func (w *WebviewWindow) ForceReload() {} + +// Info is a no-op in the stub. +func (w *WebviewWindow) Info(message string, args ...any) {} + +// Error is a no-op in the stub. +func (w *WebviewWindow) Error(message string, args ...any) {} + +// shouldUnconditionallyClose always returns false in the stub. +func (w *WebviewWindow) shouldUnconditionallyClose() bool { return false } + +// Internal editing stubs — satisfy the Window interface. +func (w *WebviewWindow) cut() {} +func (w *WebviewWindow) copy() {} +func (w *WebviewWindow) paste() {} +func (w *WebviewWindow) undo() {} +func (w *WebviewWindow) redo() {} +func (w *WebviewWindow) delete() {} +func (w *WebviewWindow) selectAll() {} + +// Title returns the current window title. +func (w *WebviewWindow) Title() string { + w.mu.RLock() + defer w.mu.RUnlock() + return w.title +} + +// WindowManager manages in-memory windows. +// +// win := app.Window.NewWithOptions(application.WebviewWindowOptions{Title: "Main"}) +type WindowManager struct { + mu sync.RWMutex + windows map[uint]*WebviewWindow +} + +func (wm *WindowManager) init() { + if wm.windows == nil { + wm.windows = make(map[uint]*WebviewWindow) + } +} + +// NewWithOptions creates and registers a new window. func (wm *WindowManager) NewWithOptions(options WebviewWindowOptions) *WebviewWindow { window := newWebviewWindow(options) wm.mu.Lock() - wm.windows = append(wm.windows, window) + wm.init() + wm.windows[window.windowID] = window wm.mu.Unlock() return window } -func (wm *WindowManager) GetAll() []any { +// New creates a window with default options. +func (wm *WindowManager) New() *WebviewWindow { + return wm.NewWithOptions(WebviewWindowOptions{}) +} + +// GetAll returns all managed windows as the Window interface slice. +func (wm *WindowManager) GetAll() []Window { wm.mu.RLock() defer wm.mu.RUnlock() - out := make([]any, 0, len(wm.windows)) + out := make([]Window, 0, len(wm.windows)) for _, window := range wm.windows { out = append(out, window) } return out } -// App is the top-level application object used by the GUI packages. -type App struct { - Logger Logger - Window WindowManager - Menu MenuManager - SystemTray SystemTrayManager +// GetByName finds a window by name, returning it and whether it was found. +func (wm *WindowManager) GetByName(name string) (Window, bool) { + wm.mu.RLock() + defer wm.mu.RUnlock() + for _, window := range wm.windows { + if window.opts.Name == name { + return window, true + } + } + return nil, false } +// GetByID finds a window by its numeric ID. +func (wm *WindowManager) GetByID(id uint) (Window, bool) { + wm.mu.RLock() + defer wm.mu.RUnlock() + window, exists := wm.windows[id] + return window, exists +} + +// Remove unregisters a window by ID. +func (wm *WindowManager) Remove(windowID uint) { + wm.mu.Lock() + wm.init() + delete(wm.windows, windowID) + wm.mu.Unlock() +} + +// App is the top-level application object used by the GUI packages. +// +// app := &application.App{} +// win := app.Window.NewWithOptions(application.WebviewWindowOptions{Title: "Main"}) +type App struct { + Logger Logger + Window WindowManager + Menu MenuManager + SystemTray SystemTrayManager + Dialog DialogManager + Event EventManager + Browser BrowserManager + Clipboard ClipboardManager + ContextMenu ContextMenuManager + Environment EnvironmentManager + Screen ScreenManager + KeyBinding KeyBindingManager +} + +// Quit is a no-op in the stub. func (a *App) Quit() {} +// NewMenu creates a new empty Menu. func (a *App) NewMenu() *Menu { return NewMenu() } diff --git a/stubs/wails/pkg/application/application_options.go b/stubs/wails/pkg/application/application_options.go new file mode 100644 index 00000000..fca6f5cd --- /dev/null +++ b/stubs/wails/pkg/application/application_options.go @@ -0,0 +1,348 @@ +package application + +import ( + "net/http" + "time" +) + +// Options is the top-level application configuration passed to New(). +// app := application.New(application.Options{Name: "MyApp", Services: []Service{...}}) +type Options struct { + // Name is displayed in the default about box. + Name string + + // Description is displayed in the default about box. + Description string + + // Icon is the application icon shown in the about box. + Icon []byte + + // Mac contains macOS-specific options. + Mac MacOptions + + // Windows contains Windows-specific options. + Windows WindowsOptions + + // Linux contains Linux-specific options. + Linux LinuxOptions + + // IOS contains iOS-specific options. + IOS IOSOptions + + // Android contains Android-specific options. + Android AndroidOptions + + // Services lists bound Go services exposed to the frontend. + Services []Service + + // MarshalError serialises service method errors to JSON. + // Return nil to fall back to the default error handler. + MarshalError func(error) []byte + + // BindAliases maps alias IDs to bound method IDs. + // Example: map[uint32]uint32{1: 1411160069} + BindAliases map[uint32]uint32 + + // Assets configures the embedded asset server. + Assets AssetOptions + + // Flags are key/value pairs available to the frontend at startup. + Flags map[string]any + + // PanicHandler is called when an unhandled panic occurs. + PanicHandler func(*PanicDetails) + + // DisableDefaultSignalHandler prevents Wails from handling OS signals. + DisableDefaultSignalHandler bool + + // KeyBindings maps accelerator strings to window callbacks. + // Example: map[string]func(Window){"Ctrl+Q": func(w Window) { w.Close() }} + KeyBindings map[string]func(window Window) + + // OnShutdown is called before the application terminates. + OnShutdown func() + + // PostShutdown is called after shutdown, just before process exit. + PostShutdown func() + + // ShouldQuit is called when the user attempts to quit. + // Return false to prevent the application from quitting. + ShouldQuit func() bool + + // RawMessageHandler handles raw messages from the frontend. + RawMessageHandler func(window Window, message string, originInfo *OriginInfo) + + // WarningHandler is called when a non-fatal warning occurs. + WarningHandler func(string) + + // ErrorHandler is called when a non-fatal error occurs. + ErrorHandler func(err error) + + // FileAssociations lists file extensions associated with this application. + // Each extension must include the leading dot, e.g. ".txt". + FileAssociations []string + + // SingleInstance configures single-instance enforcement. + SingleInstance *SingleInstanceOptions + + // Server configures the headless HTTP server (enabled via the "server" build tag). + Server ServerOptions +} + +// ServerOptions configures the headless HTTP server started in server mode. +// opts.Server = application.ServerOptions{Host: "0.0.0.0", Port: 8080} +type ServerOptions struct { + // Host is the bind address. Defaults to "localhost". + Host string + + // Port is the TCP port. Defaults to 8080. + Port int + + // ReadTimeout is the maximum duration for reading a complete request. + ReadTimeout time.Duration + + // WriteTimeout is the maximum duration before timing out a response write. + WriteTimeout time.Duration + + // IdleTimeout is the maximum duration to wait for the next request. + IdleTimeout time.Duration + + // ShutdownTimeout is the maximum duration to wait for active connections on shutdown. + ShutdownTimeout time.Duration + + // TLS configures HTTPS. If nil, plain HTTP is used. + TLS *TLSOptions +} + +// TLSOptions configures HTTPS for the headless server. +// opts.Server.TLS = &application.TLSOptions{CertFile: "cert.pem", KeyFile: "key.pem"} +type TLSOptions struct { + // CertFile is the path to the TLS certificate file. + CertFile string + + // KeyFile is the path to the TLS private key file. + KeyFile string +} + +// AssetOptions configures the embedded asset server. +// opts.Assets = application.AssetOptions{Handler: http.FileServer(http.FS(assets))} +type AssetOptions struct { + // Handler serves all content to the WebView. + Handler http.Handler + + // Middleware injects into the asset server request chain before Wails internals. + Middleware Middleware + + // DisableLogging suppresses per-request asset server log output. + DisableLogging bool +} + +// Middleware is an HTTP middleware function for the asset server. +// type Middleware func(next http.Handler) http.Handler +type Middleware func(next http.Handler) http.Handler + +// ChainMiddleware composes multiple Middleware values into a single Middleware. +// chain := application.ChainMiddleware(authMiddleware, loggingMiddleware) +func ChainMiddleware(middleware ...Middleware) Middleware { + return func(handler http.Handler) http.Handler { + for index := len(middleware) - 1; index >= 0; index-- { + handler = middleware[index](handler) + } + return handler + } +} + +// PanicDetails carries information about an unhandled panic. +// opts.PanicHandler = func(d *application.PanicDetails) { log(d.StackTrace) } +type PanicDetails struct { + StackTrace string + Error error + FullStackTrace string +} + +// OriginInfo carries frame-origin metadata for raw message handling. +// opts.RawMessageHandler = func(w Window, msg string, o *application.OriginInfo) { ... } +type OriginInfo struct { + Origin string + TopOrigin string + IsMainFrame bool +} + +// SingleInstanceOptions configures single-instance enforcement. +// opts.SingleInstance = &application.SingleInstanceOptions{UniqueID: "com.example.myapp"} +type SingleInstanceOptions struct { + // UniqueID identifies the application instance, e.g. "com.example.myapp". + UniqueID string + + // OnSecondInstanceLaunch is called when a second instance attempts to start. + OnSecondInstanceLaunch func(data SecondInstanceData) + + // AdditionalData passes custom data from the second instance to the first. + AdditionalData map[string]string +} + +// SecondInstanceData describes a second-instance launch event. +type SecondInstanceData struct { + Args []string `json:"args"` + WorkingDir string `json:"workingDir"` + AdditionalData map[string]string `json:"additionalData,omitempty"` +} + +// ActivationPolicy controls when a macOS application activates. +// opts.Mac.ActivationPolicy = application.ActivationPolicyAccessory +type ActivationPolicy int + +const ( + // ActivationPolicyRegular is used for applications with a main window. + ActivationPolicyRegular ActivationPolicy = iota + // ActivationPolicyAccessory is used for menu-bar or background applications. + ActivationPolicyAccessory + // ActivationPolicyProhibited prevents the application from activating. + ActivationPolicyProhibited +) + +// MacOptions contains macOS-specific application options. +// opts.Mac = application.MacOptions{ActivationPolicy: application.ActivationPolicyRegular} +type MacOptions struct { + // ActivationPolicy controls how and when the application becomes active. + ActivationPolicy ActivationPolicy + + // ApplicationShouldTerminateAfterLastWindowClosed quits the app when + // the last window closes, matching standard macOS behaviour. + ApplicationShouldTerminateAfterLastWindowClosed bool +} + +// WindowsOptions contains Windows-specific application options. +// opts.Windows = application.WindowsOptions{WndClass: "MyAppWindow"} +type WindowsOptions struct { + // WndClass is the Windows window class name. Defaults to "WailsWebviewWindow". + WndClass string + + // WndProcInterceptor intercepts all Win32 messages in the main message loop. + WndProcInterceptor func(hwnd uintptr, msg uint32, wParam, lParam uintptr) (returnCode uintptr, shouldReturn bool) + + // DisableQuitOnLastWindowClosed prevents auto-quit when the last window closes. + DisableQuitOnLastWindowClosed bool + + // WebviewUserDataPath sets the WebView2 user-data directory. + // Defaults to %APPDATA%\[BinaryName.exe]. + WebviewUserDataPath string + + // WebviewBrowserPath sets the directory containing WebView2 executables. + // Defaults to the system-installed WebView2. + WebviewBrowserPath string + + // EnabledFeatures lists WebView2 feature flags to enable. + EnabledFeatures []string + + // DisabledFeatures lists WebView2 feature flags to disable. + DisabledFeatures []string + + // AdditionalBrowserArgs are extra Chromium command-line arguments. + // Each argument must include the "--" prefix, e.g. "--remote-debugging-port=9222". + AdditionalBrowserArgs []string +} + +// LinuxOptions contains Linux-specific application options. +// opts.Linux = application.LinuxOptions{ProgramName: "myapp"} +type LinuxOptions struct { + // DisableQuitOnLastWindowClosed prevents auto-quit when the last window closes. + DisableQuitOnLastWindowClosed bool + + // ProgramName is passed to g_set_prgname() for window grouping in GTK. + ProgramName string +} + +// IOSOptions contains iOS-specific application options. +// opts.IOS = application.IOSOptions{EnableInlineMediaPlayback: true} +type IOSOptions struct { + // DisableInputAccessoryView hides the Next/Previous/Done toolbar above the keyboard. + DisableInputAccessoryView bool + + // DisableScroll disables WebView scrolling. + DisableScroll bool + + // DisableBounce disables the iOS overscroll bounce effect. + DisableBounce bool + + // DisableScrollIndicators hides scroll indicator bars. + DisableScrollIndicators bool + + // EnableBackForwardNavigationGestures enables swipe navigation gestures. + EnableBackForwardNavigationGestures bool + + // DisableLinkPreview disables long-press link preview (peek and pop). + DisableLinkPreview bool + + // EnableInlineMediaPlayback allows video to play inline rather than full-screen. + EnableInlineMediaPlayback bool + + // EnableAutoplayWithoutUserAction allows media to autoplay without a user gesture. + EnableAutoplayWithoutUserAction bool + + // DisableInspectable disables the Safari remote web inspector. + DisableInspectable bool + + // UserAgent overrides the WKWebView user agent string. + UserAgent string + + // ApplicationNameForUserAgent appends a name to the user agent. Defaults to "wails.io". + ApplicationNameForUserAgent string + + // AppBackgroundColourSet enables BackgroundColour for the main iOS window. + AppBackgroundColourSet bool + + // BackgroundColour is applied to the iOS app window before any WebView is created. + BackgroundColour RGBA + + // EnableNativeTabs enables a native UITabBar at the bottom of the screen. + EnableNativeTabs bool + + // NativeTabsItems configures the labels and SF Symbol icons for the native UITabBar. + NativeTabsItems []NativeTabItem +} + +// NativeTabItem describes a single tab in the iOS native UITabBar. +// item := application.NativeTabItem{Title: "Home", SystemImage: application.NativeTabIconHouse} +type NativeTabItem struct { + Title string `json:"Title"` + SystemImage NativeTabIcon `json:"SystemImage"` +} + +// NativeTabIcon is an SF Symbols name used for a UITabBar icon. +// tab := application.NativeTabItem{SystemImage: application.NativeTabIconGear} +type NativeTabIcon string + +const ( + NativeTabIconNone NativeTabIcon = "" + NativeTabIconHouse NativeTabIcon = "house" + NativeTabIconGear NativeTabIcon = "gear" + NativeTabIconStar NativeTabIcon = "star" + NativeTabIconPerson NativeTabIcon = "person" + NativeTabIconBell NativeTabIcon = "bell" + NativeTabIconMagnify NativeTabIcon = "magnifyingglass" + NativeTabIconList NativeTabIcon = "list.bullet" + NativeTabIconFolder NativeTabIcon = "folder" +) + +// AndroidOptions contains Android-specific application options. +// opts.Android = application.AndroidOptions{EnableZoom: true} +type AndroidOptions struct { + // DisableScroll disables WebView scrolling. + DisableScroll bool + + // DisableOverscroll disables the overscroll bounce effect. + DisableOverscroll bool + + // EnableZoom enables pinch-to-zoom in the WebView. + EnableZoom bool + + // UserAgent overrides the WebView user agent string. + UserAgent string + + // BackgroundColour sets the WebView background colour. + BackgroundColour RGBA + + // DisableHardwareAcceleration disables GPU acceleration for the WebView. + DisableHardwareAcceleration bool +} diff --git a/stubs/wails/pkg/application/browser_manager.go b/stubs/wails/pkg/application/browser_manager.go new file mode 100644 index 00000000..c08311f6 --- /dev/null +++ b/stubs/wails/pkg/application/browser_manager.go @@ -0,0 +1,21 @@ +package application + +// BrowserManager handles opening URLs and files in the system browser. +// +// manager.OpenURL("https://lthn.io") +// manager.OpenFile("/home/user/document.pdf") +type BrowserManager struct{} + +// OpenURL opens the given URL in the default browser. +// +// err := manager.OpenURL("https://lthn.io") +func (bm *BrowserManager) OpenURL(url string) error { + return nil +} + +// OpenFile opens the given file path in the default browser or file handler. +// +// err := manager.OpenFile("/home/user/report.html") +func (bm *BrowserManager) OpenFile(path string) error { + return nil +} diff --git a/stubs/wails/pkg/application/browser_window.go b/stubs/wails/pkg/application/browser_window.go new file mode 100644 index 00000000..e6dd7114 --- /dev/null +++ b/stubs/wails/pkg/application/browser_window.go @@ -0,0 +1,230 @@ +package application + +import ( + "sync" + "unsafe" + + "github.com/wailsapp/wails/v3/pkg/events" +) + +// ButtonState represents the visual state of a window control button. +// +// window.SetMinimiseButtonState(ButtonHidden) +type ButtonState int + +const ( + ButtonEnabled ButtonState = 0 + ButtonDisabled ButtonState = 1 + ButtonHidden ButtonState = 2 +) + +// LRTB holds left/right/top/bottom border sizes in pixels. +// +// sizes := window.GetBorderSizes() +type LRTB struct { + Left int + Right int + Top int + Bottom int +} + +// ContextMenuData carries context-menu position and metadata from the frontend. +// +// window.OpenContextMenu(&ContextMenuData{Id: "file-menu"}) +type ContextMenuData struct { + Id string `json:"id"` + X int `json:"x"` + Y int `json:"y"` + Data string `json:"data"` +} + +func (c *ContextMenuData) clone() *ContextMenuData { + if c == nil { + return nil + } + copy := *c + return © +} + +// CustomEvent carries a named event with arbitrary data from the frontend. +// +// window.DispatchWailsEvent(&CustomEvent{Name: "ready", Data: nil}) +type CustomEvent struct { + Name string `json:"name"` + Data any `json:"data"` + Sender string `json:"sender,omitempty"` + cancelled bool +} + +// Cancel prevents the event from reaching further listeners. +func (e *CustomEvent) Cancel() { e.cancelled = true } + +// IsCancelled reports whether Cancel has been called. +func (e *CustomEvent) IsCancelled() bool { return e.cancelled } + +// BrowserWindow represents a browser client connection in server mode. +// It satisfies the Window interface so browser clients are treated +// uniformly with native windows throughout the codebase. +// +// bw := NewBrowserWindow(1, "nano-abc123") +// bw.Focus() // no-op in browser mode +type BrowserWindow struct { + mu sync.RWMutex + id uint + name string + clientID string +} + +// NewBrowserWindow constructs a BrowserWindow with the given numeric ID and client nano-ID. +// +// bw := NewBrowserWindow(1, "nano-abc123") +func NewBrowserWindow(id uint, clientID string) *BrowserWindow { + return &BrowserWindow{ + id: id, + name: "browser-window", + clientID: clientID, + } +} + +// ID returns the numeric window identifier. +func (b *BrowserWindow) ID() uint { return b.id } + +// Name returns the window name. +func (b *BrowserWindow) Name() string { + b.mu.RLock() + defer b.mu.RUnlock() + return b.name +} + +// ClientID returns the runtime nano-ID for this client. +func (b *BrowserWindow) ClientID() string { + b.mu.RLock() + defer b.mu.RUnlock() + return b.clientID +} + +// No-op windowing methods — browser windows have no native chrome. + +func (b *BrowserWindow) Center() {} +func (b *BrowserWindow) Close() {} +func (b *BrowserWindow) DisableSizeConstraints() {} +func (b *BrowserWindow) EnableSizeConstraints() {} +func (b *BrowserWindow) ExecJS(_ string) {} +func (b *BrowserWindow) Focus() {} +func (b *BrowserWindow) ForceReload() {} +func (b *BrowserWindow) HideMenuBar() {} +func (b *BrowserWindow) OpenDevTools() {} +func (b *BrowserWindow) Reload() {} +func (b *BrowserWindow) Restore() {} +func (b *BrowserWindow) Run() {} +func (b *BrowserWindow) SetPosition(_ int, _ int) {} +func (b *BrowserWindow) ShowMenuBar() {} +func (b *BrowserWindow) SnapAssist() {} +func (b *BrowserWindow) ToggleFrameless() {} +func (b *BrowserWindow) ToggleFullscreen() {} +func (b *BrowserWindow) ToggleMaximise() {} +func (b *BrowserWindow) ToggleMenuBar() {} +func (b *BrowserWindow) UnFullscreen() {} +func (b *BrowserWindow) UnMaximise() {} +func (b *BrowserWindow) UnMinimise() {} +func (b *BrowserWindow) SetEnabled(_ bool) {} +func (b *BrowserWindow) Flash(_ bool) {} +func (b *BrowserWindow) SetMenu(_ *Menu) {} +func (b *BrowserWindow) SetBounds(_ Rect) {} +func (b *BrowserWindow) Zoom() {} +func (b *BrowserWindow) ZoomIn() {} +func (b *BrowserWindow) ZoomOut() {} +func (b *BrowserWindow) OpenContextMenu(_ *ContextMenuData) {} +func (b *BrowserWindow) HandleMessage(_ string) {} +func (b *BrowserWindow) HandleWindowEvent(_ uint) {} +func (b *BrowserWindow) HandleKeyEvent(_ string) {} +func (b *BrowserWindow) AttachModal(_ Window) {} + +// Internal editing stubs. +func (b *BrowserWindow) cut() {} +func (b *BrowserWindow) copy() {} +func (b *BrowserWindow) paste() {} +func (b *BrowserWindow) undo() {} +func (b *BrowserWindow) redo() {} +func (b *BrowserWindow) delete() {} +func (b *BrowserWindow) selectAll() {} + +// shouldUnconditionallyClose always returns false for browser windows. +func (b *BrowserWindow) shouldUnconditionallyClose() bool { return false } + +// Methods returning Window for chaining — always no-op for browser windows. + +func (b *BrowserWindow) Fullscreen() Window { return b } +func (b *BrowserWindow) Hide() Window { return b } +func (b *BrowserWindow) Maximise() Window { return b } +func (b *BrowserWindow) Minimise() Window { return b } +func (b *BrowserWindow) Show() Window { return b } +func (b *BrowserWindow) SetAlwaysOnTop(_ bool) Window { return b } +func (b *BrowserWindow) SetBackgroundColour(_ RGBA) Window { return b } +func (b *BrowserWindow) SetFrameless(_ bool) Window { return b } +func (b *BrowserWindow) SetHTML(_ string) Window { return b } +func (b *BrowserWindow) SetMinimiseButtonState(_ ButtonState) Window { return b } +func (b *BrowserWindow) SetMaximiseButtonState(_ ButtonState) Window { return b } +func (b *BrowserWindow) SetCloseButtonState(_ ButtonState) Window { return b } +func (b *BrowserWindow) SetMaxSize(_ int, _ int) Window { return b } +func (b *BrowserWindow) SetMinSize(_ int, _ int) Window { return b } +func (b *BrowserWindow) SetRelativePosition(_ int, _ int) Window { return b } +func (b *BrowserWindow) SetResizable(_ bool) Window { return b } +func (b *BrowserWindow) SetIgnoreMouseEvents(_ bool) Window { return b } +func (b *BrowserWindow) SetSize(_ int, _ int) Window { return b } +func (b *BrowserWindow) SetTitle(_ string) Window { return b } +func (b *BrowserWindow) SetURL(_ string) Window { return b } +func (b *BrowserWindow) SetZoom(_ float64) Window { return b } +func (b *BrowserWindow) SetContentProtection(_ bool) Window { return b } +func (b *BrowserWindow) ZoomReset() Window { return b } + +// Methods returning simple zero values. + +func (b *BrowserWindow) GetBorderSizes() *LRTB { return nil } +func (b *BrowserWindow) GetScreen() (*Screen, error) { return nil, nil } +func (b *BrowserWindow) GetZoom() float64 { return 1.0 } +func (b *BrowserWindow) Height() int { return 0 } +func (b *BrowserWindow) Width() int { return 0 } +func (b *BrowserWindow) IsFocused() bool { return false } +func (b *BrowserWindow) IsFullscreen() bool { return false } +func (b *BrowserWindow) IsIgnoreMouseEvents() bool { return false } +func (b *BrowserWindow) IsMaximised() bool { return false } +func (b *BrowserWindow) IsMinimised() bool { return false } +func (b *BrowserWindow) IsVisible() bool { return true } +func (b *BrowserWindow) Resizable() bool { return false } +func (b *BrowserWindow) Position() (int, int) { return 0, 0 } +func (b *BrowserWindow) RelativePosition() (int, int) { return 0, 0 } +func (b *BrowserWindow) Size() (int, int) { return 0, 0 } +func (b *BrowserWindow) Bounds() Rect { return Rect{} } +func (b *BrowserWindow) NativeWindow() unsafe.Pointer { return nil } +func (b *BrowserWindow) Print() error { return nil } + +// DispatchWailsEvent is a no-op for browser windows; events are broadcast via WebSocket. +func (b *BrowserWindow) DispatchWailsEvent(_ *CustomEvent) {} + +// EmitEvent broadcasts a named event; always returns false in the stub. +func (b *BrowserWindow) EmitEvent(_ string, _ ...any) bool { return false } + +// Error logs an error message (no-op in the stub). +func (b *BrowserWindow) Error(_ string, _ ...any) {} + +// Info logs an info message (no-op in the stub). +func (b *BrowserWindow) Info(_ string, _ ...any) {} + +// OnWindowEvent registers a callback for a window event type; returns an unsubscribe func. +// +// unsubscribe := bw.OnWindowEvent(events.Common.WindowClosing, fn) +func (b *BrowserWindow) OnWindowEvent(_ events.WindowEventType, _ func(*WindowEvent)) func() { + return func() {} +} + +// RegisterHook registers a lifecycle hook; returns an unsubscribe func. +func (b *BrowserWindow) RegisterHook(_ events.WindowEventType, _ func(*WindowEvent)) func() { + return func() {} +} + +// handleDragAndDropMessage is a no-op for browser windows. +func (b *BrowserWindow) handleDragAndDropMessage(_ []string, _ *DropTargetDetails) {} + +// InitiateFrontendDropProcessing is a no-op for browser windows. +func (b *BrowserWindow) InitiateFrontendDropProcessing(_ []string, _ int, _ int) {} diff --git a/stubs/wails/pkg/application/clipboard.go b/stubs/wails/pkg/application/clipboard.go new file mode 100644 index 00000000..a26238ef --- /dev/null +++ b/stubs/wails/pkg/application/clipboard.go @@ -0,0 +1,65 @@ +package application + +import "sync" + +// Clipboard provides direct read/write access to the system clipboard. +// +// ok := clipboard.SetText("hello") +// text, ok := clipboard.Text() +type Clipboard struct { + mu sync.RWMutex + text string +} + +// SetText writes the given text to the clipboard and returns true on success. +// +// ok := clipboard.SetText("copied text") +func (c *Clipboard) SetText(text string) bool { + c.mu.Lock() + c.text = text + c.mu.Unlock() + return true +} + +// Text reads the current clipboard text. Returns the text and true on success. +// +// text, ok := clipboard.Text() +func (c *Clipboard) Text() (string, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + return c.text, true +} + +// ClipboardManager is the application-level clipboard surface. +// Lazily initialises the underlying Clipboard on first use. +// +// manager.SetText("hello") +// text, ok := manager.Text() +type ClipboardManager struct { + mu sync.Mutex + clipboard *Clipboard +} + +// SetText writes text to the clipboard and returns true on success. +// +// ok := manager.SetText("hello") +func (cm *ClipboardManager) SetText(text string) bool { + return cm.instance().SetText(text) +} + +// Text reads the current clipboard text. Returns the text and true on success. +// +// text, ok := manager.Text() +func (cm *ClipboardManager) Text() (string, bool) { + return cm.instance().Text() +} + +// instance returns the Clipboard, creating it if it does not yet exist. +func (cm *ClipboardManager) instance() *Clipboard { + cm.mu.Lock() + defer cm.mu.Unlock() + if cm.clipboard == nil { + cm.clipboard = &Clipboard{} + } + return cm.clipboard +} diff --git a/stubs/wails/pkg/application/context_menu.go b/stubs/wails/pkg/application/context_menu.go new file mode 100644 index 00000000..9acd14a4 --- /dev/null +++ b/stubs/wails/pkg/application/context_menu.go @@ -0,0 +1,73 @@ +package application + +import "sync" + +// ContextMenu is a named Menu used as a right-click context menu. +// +// cm := manager.New() +// cm.Add("Cut").OnClick(func(*Context) { ... }) +type ContextMenu struct { + *Menu + name string +} + +// ContextMenuManager manages named context menus for the application. +// +// manager.Add("fileList", cm) +// menu, ok := manager.Get("fileList") +type ContextMenuManager struct { + mu sync.RWMutex + menus map[string]*ContextMenu +} + +// New creates an empty, unnamed ContextMenu ready for population. +// +// cm := manager.New() +// cm.Add("Open") +func (cmm *ContextMenuManager) New() *ContextMenu { + return &ContextMenu{Menu: NewMenu()} +} + +// Add registers a ContextMenu under the given name, replacing any existing entry. +// +// manager.Add("fileList", cm) +func (cmm *ContextMenuManager) Add(name string, menu *ContextMenu) { + cmm.mu.Lock() + defer cmm.mu.Unlock() + if cmm.menus == nil { + cmm.menus = make(map[string]*ContextMenu) + } + cmm.menus[name] = menu +} + +// Remove unregisters the context menu with the given name. +// +// manager.Remove("fileList") +func (cmm *ContextMenuManager) Remove(name string) { + cmm.mu.Lock() + defer cmm.mu.Unlock() + delete(cmm.menus, name) +} + +// Get retrieves a registered context menu by name. +// +// menu, ok := manager.Get("fileList") +func (cmm *ContextMenuManager) Get(name string) (*ContextMenu, bool) { + cmm.mu.RLock() + defer cmm.mu.RUnlock() + menu, exists := cmm.menus[name] + return menu, exists +} + +// GetAll returns all registered context menus as a slice. +// +// for _, cm := range manager.GetAll() { ... } +func (cmm *ContextMenuManager) GetAll() []*ContextMenu { + cmm.mu.RLock() + defer cmm.mu.RUnlock() + result := make([]*ContextMenu, 0, len(cmm.menus)) + for _, menu := range cmm.menus { + result = append(result, menu) + } + return result +} diff --git a/stubs/wails/pkg/application/dialog.go b/stubs/wails/pkg/application/dialog.go new file mode 100644 index 00000000..4a5090a2 --- /dev/null +++ b/stubs/wails/pkg/application/dialog.go @@ -0,0 +1,481 @@ +package application + +// DialogType identifies the visual style of a message dialog. +type DialogType int + +const ( + InfoDialogType DialogType = iota + QuestionDialogType DialogType = iota + WarningDialogType DialogType = iota + ErrorDialogType DialogType = iota +) + +// FileFilter describes a file type filter for open/save dialogs. +// +// filter := FileFilter{DisplayName: "Images (*.png;*.jpg)", Pattern: "*.png;*.jpg"} +type FileFilter struct { + DisplayName string + Pattern string +} + +// Button is a labelled action in a MessageDialog. +// +// btn := dialog.AddButton("OK") +// btn.SetAsDefault().OnClick(func() { ... }) +type Button struct { + Label string + IsCancel bool + IsDefault bool + Callback func() +} + +// OnClick registers a click handler on the button and returns itself for chaining. +// +// btn.OnClick(func() { saveFile() }) +func (b *Button) OnClick(callback func()) *Button { + b.Callback = callback + return b +} + +// SetAsDefault marks this button as the default (Enter key) action. +func (b *Button) SetAsDefault() *Button { + b.IsDefault = true + return b +} + +// SetAsCancel marks this button as the cancel (Escape key) action. +func (b *Button) SetAsCancel() *Button { + b.IsCancel = true + return b +} + +// MessageDialogOptions holds configuration for a MessageDialog. +type MessageDialogOptions struct { + DialogType DialogType + Title string + Message string + Buttons []*Button + Icon []byte +} + +// MessageDialog is an in-memory message dialog (info / question / warning / error). +// +// dialog.Info().SetTitle("Done").SetMessage("File saved.").Show() +type MessageDialog struct { + MessageDialogOptions +} + +// SetTitle sets the dialog window title. +// +// dialog.SetTitle("Confirm Delete") +func (d *MessageDialog) SetTitle(title string) *MessageDialog { + d.Title = title + return d +} + +// SetMessage sets the body text shown in the dialog. +// +// dialog.SetMessage("Are you sure?") +func (d *MessageDialog) SetMessage(message string) *MessageDialog { + d.Message = message + return d +} + +// SetIcon sets the icon bytes shown in the dialog. +func (d *MessageDialog) SetIcon(icon []byte) *MessageDialog { + d.Icon = icon + return d +} + +// AddButton appends a labelled button and returns it for further configuration. +// +// btn := dialog.AddButton("Yes") +func (d *MessageDialog) AddButton(label string) *Button { + btn := &Button{Label: label} + d.Buttons = append(d.Buttons, btn) + return btn +} + +// AddButtons replaces the button list in bulk. +func (d *MessageDialog) AddButtons(buttons []*Button) *MessageDialog { + d.Buttons = buttons + return d +} + +// SetDefaultButton marks the given button as the default action. +func (d *MessageDialog) SetDefaultButton(button *Button) *MessageDialog { + for _, b := range d.Buttons { + b.IsDefault = false + } + button.IsDefault = true + return d +} + +// SetCancelButton marks the given button as the cancel action. +func (d *MessageDialog) SetCancelButton(button *Button) *MessageDialog { + for _, b := range d.Buttons { + b.IsCancel = false + } + button.IsCancel = true + return d +} + +// AttachToWindow associates the dialog with a parent window (no-op in the stub). +func (d *MessageDialog) AttachToWindow(window *WebviewWindow) *MessageDialog { + return d +} + +// Show presents the dialog. No-op in the stub. +func (d *MessageDialog) Show() {} + +func newMessageDialog(dialogType DialogType) *MessageDialog { + return &MessageDialog{ + MessageDialogOptions: MessageDialogOptions{DialogType: dialogType}, + } +} + +// OpenFileDialogOptions configures an OpenFileDialogStruct. +type OpenFileDialogOptions struct { + CanChooseDirectories bool + CanChooseFiles bool + CanCreateDirectories bool + ShowHiddenFiles bool + ResolvesAliases bool + AllowsMultipleSelection bool + HideExtension bool + CanSelectHiddenExtension bool + TreatsFilePackagesAsDirectories bool + AllowsOtherFileTypes bool + Filters []FileFilter + Title string + Message string + ButtonText string + Directory string +} + +// OpenFileDialogStruct is a builder for file-open dialogs. +// +// path, err := manager.OpenFile().SetTitle("Pick a file").PromptForSingleSelection() +type OpenFileDialogStruct struct { + canChooseDirectories bool + canChooseFiles bool + canCreateDirectories bool + showHiddenFiles bool + resolvesAliases bool + allowsMultipleSelection bool + hideExtension bool + canSelectHiddenExtension bool + treatsFilePackagesAsDirectories bool + allowsOtherFileTypes bool + filters []FileFilter + title string + message string + buttonText string + directory string +} + +func newOpenFileDialog() *OpenFileDialogStruct { + return &OpenFileDialogStruct{ + canChooseFiles: true, + canCreateDirectories: true, + } +} + +// SetOptions applies all fields from OpenFileDialogOptions to the dialog. +func (d *OpenFileDialogStruct) SetOptions(options *OpenFileDialogOptions) { + d.title = options.Title + d.message = options.Message + d.buttonText = options.ButtonText + d.directory = options.Directory + d.canChooseDirectories = options.CanChooseDirectories + d.canChooseFiles = options.CanChooseFiles + d.canCreateDirectories = options.CanCreateDirectories + d.showHiddenFiles = options.ShowHiddenFiles + d.resolvesAliases = options.ResolvesAliases + d.allowsMultipleSelection = options.AllowsMultipleSelection + d.hideExtension = options.HideExtension + d.canSelectHiddenExtension = options.CanSelectHiddenExtension + d.treatsFilePackagesAsDirectories = options.TreatsFilePackagesAsDirectories + d.allowsOtherFileTypes = options.AllowsOtherFileTypes + d.filters = options.Filters +} + +func (d *OpenFileDialogStruct) SetTitle(title string) *OpenFileDialogStruct { + d.title = title + return d +} + +func (d *OpenFileDialogStruct) SetMessage(message string) *OpenFileDialogStruct { + d.message = message + return d +} + +func (d *OpenFileDialogStruct) SetButtonText(text string) *OpenFileDialogStruct { + d.buttonText = text + return d +} + +func (d *OpenFileDialogStruct) SetDirectory(directory string) *OpenFileDialogStruct { + d.directory = directory + return d +} + +func (d *OpenFileDialogStruct) CanChooseFiles(canChooseFiles bool) *OpenFileDialogStruct { + d.canChooseFiles = canChooseFiles + return d +} + +func (d *OpenFileDialogStruct) CanChooseDirectories(canChooseDirectories bool) *OpenFileDialogStruct { + d.canChooseDirectories = canChooseDirectories + return d +} + +func (d *OpenFileDialogStruct) CanCreateDirectories(canCreateDirectories bool) *OpenFileDialogStruct { + d.canCreateDirectories = canCreateDirectories + return d +} + +func (d *OpenFileDialogStruct) ShowHiddenFiles(showHiddenFiles bool) *OpenFileDialogStruct { + d.showHiddenFiles = showHiddenFiles + return d +} + +func (d *OpenFileDialogStruct) HideExtension(hideExtension bool) *OpenFileDialogStruct { + d.hideExtension = hideExtension + return d +} + +func (d *OpenFileDialogStruct) CanSelectHiddenExtension(canSelectHiddenExtension bool) *OpenFileDialogStruct { + d.canSelectHiddenExtension = canSelectHiddenExtension + return d +} + +func (d *OpenFileDialogStruct) ResolvesAliases(resolvesAliases bool) *OpenFileDialogStruct { + d.resolvesAliases = resolvesAliases + return d +} + +func (d *OpenFileDialogStruct) AllowsOtherFileTypes(allowsOtherFileTypes bool) *OpenFileDialogStruct { + d.allowsOtherFileTypes = allowsOtherFileTypes + return d +} + +func (d *OpenFileDialogStruct) TreatsFilePackagesAsDirectories(treats bool) *OpenFileDialogStruct { + d.treatsFilePackagesAsDirectories = treats + return d +} + +// AddFilter appends a file type filter to the dialog. +// +// dialog.AddFilter("Images", "*.png;*.jpg") +func (d *OpenFileDialogStruct) AddFilter(displayName, pattern string) *OpenFileDialogStruct { + d.filters = append(d.filters, FileFilter{DisplayName: displayName, Pattern: pattern}) + return d +} + +func (d *OpenFileDialogStruct) AttachToWindow(window *WebviewWindow) *OpenFileDialogStruct { + return d +} + +// PromptForSingleSelection shows the dialog and returns the chosen path. +// Always returns ("", nil) in the stub. +// +// path, err := dialog.PromptForSingleSelection() +func (d *OpenFileDialogStruct) PromptForSingleSelection() (string, error) { + return "", nil +} + +// PromptForMultipleSelection shows the dialog and returns all chosen paths. +// Always returns (nil, nil) in the stub. +// +// paths, err := dialog.PromptForMultipleSelection() +func (d *OpenFileDialogStruct) PromptForMultipleSelection() ([]string, error) { + return nil, nil +} + +// SaveFileDialogOptions configures a SaveFileDialogStruct. +type SaveFileDialogOptions struct { + CanCreateDirectories bool + ShowHiddenFiles bool + CanSelectHiddenExtension bool + AllowOtherFileTypes bool + HideExtension bool + TreatsFilePackagesAsDirectories bool + Title string + Message string + Directory string + Filename string + ButtonText string + Filters []FileFilter +} + +// SaveFileDialogStruct is a builder for file-save dialogs. +// +// path, err := manager.SaveFile().SetTitle("Save As").PromptForSingleSelection() +type SaveFileDialogStruct struct { + canCreateDirectories bool + showHiddenFiles bool + canSelectHiddenExtension bool + allowOtherFileTypes bool + hideExtension bool + treatsFilePackagesAsDirectories bool + title string + message string + directory string + filename string + buttonText string + filters []FileFilter +} + +func newSaveFileDialog() *SaveFileDialogStruct { + return &SaveFileDialogStruct{canCreateDirectories: true} +} + +// SetOptions applies all fields from SaveFileDialogOptions to the dialog. +func (d *SaveFileDialogStruct) SetOptions(options *SaveFileDialogOptions) { + d.title = options.Title + d.canCreateDirectories = options.CanCreateDirectories + d.showHiddenFiles = options.ShowHiddenFiles + d.canSelectHiddenExtension = options.CanSelectHiddenExtension + d.allowOtherFileTypes = options.AllowOtherFileTypes + d.hideExtension = options.HideExtension + d.treatsFilePackagesAsDirectories = options.TreatsFilePackagesAsDirectories + d.message = options.Message + d.directory = options.Directory + d.filename = options.Filename + d.buttonText = options.ButtonText + d.filters = options.Filters +} + +func (d *SaveFileDialogStruct) SetTitle(title string) *SaveFileDialogStruct { + d.title = title + return d +} + +func (d *SaveFileDialogStruct) SetMessage(message string) *SaveFileDialogStruct { + d.message = message + return d +} + +func (d *SaveFileDialogStruct) SetDirectory(directory string) *SaveFileDialogStruct { + d.directory = directory + return d +} + +func (d *SaveFileDialogStruct) SetFilename(filename string) *SaveFileDialogStruct { + d.filename = filename + return d +} + +func (d *SaveFileDialogStruct) SetButtonText(text string) *SaveFileDialogStruct { + d.buttonText = text + return d +} + +func (d *SaveFileDialogStruct) CanCreateDirectories(canCreateDirectories bool) *SaveFileDialogStruct { + d.canCreateDirectories = canCreateDirectories + return d +} + +func (d *SaveFileDialogStruct) ShowHiddenFiles(showHiddenFiles bool) *SaveFileDialogStruct { + d.showHiddenFiles = showHiddenFiles + return d +} + +func (d *SaveFileDialogStruct) CanSelectHiddenExtension(canSelectHiddenExtension bool) *SaveFileDialogStruct { + d.canSelectHiddenExtension = canSelectHiddenExtension + return d +} + +func (d *SaveFileDialogStruct) AllowsOtherFileTypes(allowOtherFileTypes bool) *SaveFileDialogStruct { + d.allowOtherFileTypes = allowOtherFileTypes + return d +} + +func (d *SaveFileDialogStruct) HideExtension(hideExtension bool) *SaveFileDialogStruct { + d.hideExtension = hideExtension + return d +} + +func (d *SaveFileDialogStruct) TreatsFilePackagesAsDirectories(treats bool) *SaveFileDialogStruct { + d.treatsFilePackagesAsDirectories = treats + return d +} + +// AddFilter appends a file type filter to the dialog. +// +// dialog.AddFilter("Text files", "*.txt") +func (d *SaveFileDialogStruct) AddFilter(displayName, pattern string) *SaveFileDialogStruct { + d.filters = append(d.filters, FileFilter{DisplayName: displayName, Pattern: pattern}) + return d +} + +func (d *SaveFileDialogStruct) AttachToWindow(window *WebviewWindow) *SaveFileDialogStruct { + return d +} + +// PromptForSingleSelection shows the save dialog and returns the chosen path. +// Always returns ("", nil) in the stub. +// +// path, err := dialog.PromptForSingleSelection() +func (d *SaveFileDialogStruct) PromptForSingleSelection() (string, error) { + return "", nil +} + +// DialogManager exposes factory methods for all dialog types. +// +// manager.Info().SetMessage("Saved!").Show() +// path, _ := manager.OpenFile().PromptForSingleSelection() +type DialogManager struct{} + +// OpenFile creates a file-open dialog builder. +func (dm *DialogManager) OpenFile() *OpenFileDialogStruct { + return newOpenFileDialog() +} + +// OpenFileWithOptions creates a file-open dialog builder pre-populated from options. +func (dm *DialogManager) OpenFileWithOptions(options *OpenFileDialogOptions) *OpenFileDialogStruct { + result := newOpenFileDialog() + result.SetOptions(options) + return result +} + +// SaveFile creates a file-save dialog builder. +func (dm *DialogManager) SaveFile() *SaveFileDialogStruct { + return newSaveFileDialog() +} + +// SaveFileWithOptions creates a file-save dialog builder pre-populated from options. +func (dm *DialogManager) SaveFileWithOptions(options *SaveFileDialogOptions) *SaveFileDialogStruct { + result := newSaveFileDialog() + result.SetOptions(options) + return result +} + +// Info creates an information message dialog. +// +// manager.Info().SetMessage("Done").Show() +func (dm *DialogManager) Info() *MessageDialog { + return newMessageDialog(InfoDialogType) +} + +// Question creates a question message dialog. +// +// manager.Question().SetMessage("Continue?").Show() +func (dm *DialogManager) Question() *MessageDialog { + return newMessageDialog(QuestionDialogType) +} + +// Warning creates a warning message dialog. +// +// manager.Warning().SetMessage("Low disk space").Show() +func (dm *DialogManager) Warning() *MessageDialog { + return newMessageDialog(WarningDialogType) +} + +// Error creates an error message dialog. +// +// manager.Error().SetMessage("Write failed").Show() +func (dm *DialogManager) Error() *MessageDialog { + return newMessageDialog(ErrorDialogType) +} diff --git a/stubs/wails/pkg/application/environment.go b/stubs/wails/pkg/application/environment.go new file mode 100644 index 00000000..fe9f982e --- /dev/null +++ b/stubs/wails/pkg/application/environment.go @@ -0,0 +1,63 @@ +package application + +// EnvironmentInfo holds runtime information about the host OS and build. +// +// info := manager.Info() +// fmt.Println(info.OS, info.Arch) +type EnvironmentInfo struct { + OS string + Arch string + Debug bool + PlatformInfo map[string]any +} + +// EnvironmentManager provides queries about the host OS environment. +// +// if manager.IsDarkMode() { applyDarkTheme() } +// accent := manager.GetAccentColor() +type EnvironmentManager struct { + darkMode bool + accentColor string +} + +// IsDarkMode returns true when the OS is using a dark colour scheme. +// +// if manager.IsDarkMode() { applyDarkTheme() } +func (em *EnvironmentManager) IsDarkMode() bool { + return em.darkMode +} + +// GetAccentColor returns the OS accent colour as an rgb() string. +// +// colour := manager.GetAccentColor() // e.g. "rgb(0,122,255)" +func (em *EnvironmentManager) GetAccentColor() string { + if em.accentColor == "" { + return "rgb(0,122,255)" + } + return em.accentColor +} + +// Info returns a snapshot of OS and build environment information. +// +// info := manager.Info() +func (em *EnvironmentManager) Info() EnvironmentInfo { + return EnvironmentInfo{ + PlatformInfo: make(map[string]any), + } +} + +// OpenFileManager opens the file manager at the given path, optionally selecting the file. +// No-op in the stub. +// +// err := manager.OpenFileManager("/home/user/docs", false) +func (em *EnvironmentManager) OpenFileManager(path string, selectFile bool) error { + return nil +} + +// HasFocusFollowsMouse reports whether the Linux desktop is configured for +// focus-follows-mouse. Always returns false in the stub. +// +// if manager.HasFocusFollowsMouse() { adjustMouseBehaviour() } +func (em *EnvironmentManager) HasFocusFollowsMouse() bool { + return false +} diff --git a/stubs/wails/pkg/application/events.go b/stubs/wails/pkg/application/events.go new file mode 100644 index 00000000..184cac00 --- /dev/null +++ b/stubs/wails/pkg/application/events.go @@ -0,0 +1,297 @@ +package application + +import ( + "sync" + "sync/atomic" + + "github.com/wailsapp/wails/v3/pkg/events" +) + +// ApplicationEventContext carries structured data for an ApplicationEvent. +// +// files := event.Context().OpenedFiles() +// dark := event.Context().IsDarkMode() +type ApplicationEventContext struct { + data map[string]any +} + +// OpenedFiles returns the list of files provided via the event context, or nil. +// +// for _, path := range event.Context().OpenedFiles() { open(path) } +func (c *ApplicationEventContext) OpenedFiles() []string { + if c.data == nil { + return nil + } + files, ok := c.data["openedFiles"] + if !ok { + return nil + } + result, ok := files.([]string) + if !ok { + return nil + } + return result +} + +// IsDarkMode returns true when the event context reports dark mode active. +// +// if event.Context().IsDarkMode() { applyDark() } +func (c *ApplicationEventContext) IsDarkMode() bool { + return c.getBool("isDarkMode") +} + +// HasVisibleWindows returns true when the event context reports at least one visible window. +// +// if event.Context().HasVisibleWindows() { ... } +func (c *ApplicationEventContext) HasVisibleWindows() bool { + return c.getBool("hasVisibleWindows") +} + +// Filename returns the filename value from the event context, or "". +// +// path := event.Context().Filename() +func (c *ApplicationEventContext) Filename() string { + if c.data == nil { + return "" + } + v, ok := c.data["filename"] + if !ok { + return "" + } + result, ok := v.(string) + if !ok { + return "" + } + return result +} + +// URL returns the URL value from the event context, or "". +// +// url := event.Context().URL() +func (c *ApplicationEventContext) URL() string { + if c.data == nil { + return "" + } + v, ok := c.data["url"] + if !ok { + return "" + } + result, ok := v.(string) + if !ok { + return "" + } + return result +} + +func (c *ApplicationEventContext) getBool(key string) bool { + if c.data == nil { + return false + } + v, ok := c.data[key] + if !ok { + return false + } + result, ok := v.(bool) + if !ok { + return false + } + return result +} + +// ApplicationEvent is the event object delivered to OnApplicationEvent listeners. +// +// em.OnApplicationEvent(events.Common.ThemeChanged, func(e *application.ApplicationEvent) { +// dark := e.Context().IsDarkMode() +// }) +type ApplicationEvent struct { + Id uint + ctx *ApplicationEventContext + cancelled atomic.Bool +} + +// Context returns the ApplicationEventContext attached to the event. +func (e *ApplicationEvent) Context() *ApplicationEventContext { + if e.ctx == nil { + e.ctx = &ApplicationEventContext{data: make(map[string]any)} + } + return e.ctx +} + +// Cancel marks the event as cancelled, preventing further listener dispatch. +func (e *ApplicationEvent) Cancel() { + e.cancelled.Store(true) +} + +// IsCancelled reports whether the event has been cancelled. +func (e *ApplicationEvent) IsCancelled() bool { + return e.cancelled.Load() +} + +// customEventListener is an internal listener registration. +type customEventListener struct { + callback func(*CustomEvent) + counter int // -1 = unlimited +} + +// applicationEventListener is an internal listener registration. +type applicationEventListener struct { + callback func(*ApplicationEvent) +} + +// EventManager manages custom and application-level event subscriptions. +// +// em.Emit("build:done", result) +// cancel := em.On("build:done", func(e *application.CustomEvent) { ... }) +// em.OnApplicationEvent(events.Common.ThemeChanged, func(e *application.ApplicationEvent) { ... }) +type EventManager struct { + mu sync.RWMutex + + customListeners map[string][]*customEventListener + applicationListeners map[uint][]*applicationEventListener +} + +func (em *EventManager) ensureCustomListeners() { + if em.customListeners == nil { + em.customListeners = make(map[string][]*customEventListener) + } +} + +func (em *EventManager) ensureApplicationListeners() { + if em.applicationListeners == nil { + em.applicationListeners = make(map[uint][]*applicationEventListener) + } +} + +// Emit emits a custom event by name with optional data arguments. +// Returns true if the event was cancelled by a listener. +// +// cancelled := em.Emit("build:done", buildResult) +func (em *EventManager) Emit(name string, data ...any) bool { + event := &CustomEvent{Name: name} + if len(data) == 1 { + event.Data = data[0] + } else if len(data) > 1 { + event.Data = data + } + return em.EmitEvent(event) +} + +// EmitEvent emits a pre-constructed CustomEvent. +// Returns true if the event was cancelled by a listener. +// +// cancelled := em.EmitEvent(&application.CustomEvent{Name: "ping"}) +func (em *EventManager) EmitEvent(event *CustomEvent) bool { + em.mu.RLock() + listeners := append([]*customEventListener(nil), em.customListeners[event.Name]...) + em.mu.RUnlock() + + toRemove := []int{} + for index, listener := range listeners { + if event.IsCancelled() { + break + } + listener.callback(event) + if listener.counter > 0 { + listener.counter-- + if listener.counter == 0 { + toRemove = append(toRemove, index) + } + } + } + + if len(toRemove) > 0 { + em.mu.Lock() + remaining := em.customListeners[event.Name] + for _, index := range toRemove { + if index < len(remaining) { + remaining = append(remaining[:index], remaining[index+1:]...) + } + } + em.customListeners[event.Name] = remaining + em.mu.Unlock() + } + + return event.IsCancelled() +} + +// On registers a persistent listener for the named custom event. +// Returns a cancel function that deregisters the listener. +// +// cancel := em.On("build:done", func(e *application.CustomEvent) { ... }) +// defer cancel() +func (em *EventManager) On(name string, callback func(event *CustomEvent)) func() { + em.mu.Lock() + em.ensureCustomListeners() + listener := &customEventListener{callback: callback, counter: -1} + em.customListeners[name] = append(em.customListeners[name], listener) + em.mu.Unlock() + + return func() { + em.mu.Lock() + defer em.mu.Unlock() + slice := em.customListeners[name] + for i, l := range slice { + if l == listener { + em.customListeners[name] = append(slice[:i], slice[i+1:]...) + return + } + } + } +} + +// Off removes all listeners for the named custom event. +// +// em.Off("build:done") +func (em *EventManager) Off(name string) { + em.mu.Lock() + delete(em.customListeners, name) + em.mu.Unlock() +} + +// OnMultiple registers a listener that fires at most counter times, then auto-deregisters. +// +// em.OnMultiple("ping", func(e *application.CustomEvent) { ... }, 3) +func (em *EventManager) OnMultiple(name string, callback func(event *CustomEvent), counter int) { + em.mu.Lock() + em.ensureCustomListeners() + em.customListeners[name] = append(em.customListeners[name], &customEventListener{ + callback: callback, + counter: counter, + }) + em.mu.Unlock() +} + +// Reset removes all custom event listeners. +// +// em.Reset() +func (em *EventManager) Reset() { + em.mu.Lock() + em.customListeners = make(map[string][]*customEventListener) + em.mu.Unlock() +} + +// OnApplicationEvent registers a listener for a platform application event. +// Returns a cancel function that deregisters the listener. +// +// cancel := em.OnApplicationEvent(events.Common.ThemeChanged, func(e *application.ApplicationEvent) { ... }) +// defer cancel() +func (em *EventManager) OnApplicationEvent(eventType events.ApplicationEventType, callback func(event *ApplicationEvent)) func() { + eventID := uint(eventType) + em.mu.Lock() + em.ensureApplicationListeners() + listener := &applicationEventListener{callback: callback} + em.applicationListeners[eventID] = append(em.applicationListeners[eventID], listener) + em.mu.Unlock() + + return func() { + em.mu.Lock() + defer em.mu.Unlock() + slice := em.applicationListeners[eventID] + for i, l := range slice { + if l == listener { + em.applicationListeners[eventID] = append(slice[:i], slice[i+1:]...) + return + } + } + } +} diff --git a/stubs/wails/pkg/application/keybinding.go b/stubs/wails/pkg/application/keybinding.go new file mode 100644 index 00000000..944539d3 --- /dev/null +++ b/stubs/wails/pkg/application/keybinding.go @@ -0,0 +1,69 @@ +package application + +import "sync" + +// KeyBinding pairs an accelerator string with its callback. +// binding := &KeyBinding{Accelerator: "Ctrl+K", Callback: fn} +type KeyBinding struct { + Accelerator string + Callback func(window Window) +} + +// KeyBindingManager holds all registered global key bindings in memory. +// manager.Add("Ctrl+K", fn) — manager.Remove("Ctrl+K") — manager.GetAll() +type KeyBindingManager struct { + mu sync.RWMutex + bindings map[string]func(window Window) +} + +// NewKeyBindingManager constructs an empty KeyBindingManager. +// manager := NewKeyBindingManager() +func NewKeyBindingManager() *KeyBindingManager { + return &KeyBindingManager{ + bindings: make(map[string]func(window Window)), + } +} + +// Add registers a callback for the given accelerator string. +// manager.Add("Ctrl+Shift+P", func(w Window) { w.Focus() }) +func (m *KeyBindingManager) Add(accelerator string, callback func(window Window)) { + m.mu.Lock() + m.bindings[accelerator] = callback + m.mu.Unlock() +} + +// Remove deletes the binding for the given accelerator. +// manager.Remove("Ctrl+Shift+P") +func (m *KeyBindingManager) Remove(accelerator string) { + m.mu.Lock() + delete(m.bindings, accelerator) + m.mu.Unlock() +} + +// Process fires the callback for accelerator if registered, returning true when handled. +// handled := manager.Process("Ctrl+K", window) +func (m *KeyBindingManager) Process(accelerator string, window Window) bool { + m.mu.RLock() + callback, exists := m.bindings[accelerator] + m.mu.RUnlock() + if exists && callback != nil { + callback(window) + return true + } + return false +} + +// GetAll returns a snapshot of all registered bindings. +// for _, b := range manager.GetAll() { fmt.Println(b.Accelerator) } +func (m *KeyBindingManager) GetAll() []*KeyBinding { + m.mu.RLock() + defer m.mu.RUnlock() + result := make([]*KeyBinding, 0, len(m.bindings)) + for accelerator, callback := range m.bindings { + result = append(result, &KeyBinding{ + Accelerator: accelerator, + Callback: callback, + }) + } + return result +} diff --git a/stubs/wails/pkg/application/menuitem.go b/stubs/wails/pkg/application/menuitem.go new file mode 100644 index 00000000..74d55fb4 --- /dev/null +++ b/stubs/wails/pkg/application/menuitem.go @@ -0,0 +1,379 @@ +package application + +import "sync/atomic" + +// Role identifies a platform-defined menu role. +// +// item := NewRole(Quit) // produces the platform quit item +type Role uint + +const ( + NoRole Role = iota + AppMenu Role = iota + EditMenu Role = iota + ViewMenu Role = iota + WindowMenu Role = iota + ServicesMenu Role = iota + HelpMenu Role = iota + SpeechMenu Role = iota + FileMenu Role = iota + + Hide Role = iota + HideOthers Role = iota + ShowAll Role = iota + BringAllToFront Role = iota + UnHide Role = iota + About Role = iota + Undo Role = iota + Redo Role = iota + Cut Role = iota + Copy Role = iota + Paste Role = iota + PasteAndMatchStyle Role = iota + SelectAll Role = iota + Delete Role = iota + Quit Role = iota + CloseWindow Role = iota + Reload Role = iota + ForceReload Role = iota + OpenDevTools Role = iota + ResetZoom Role = iota + ZoomIn Role = iota + ZoomOut Role = iota + ToggleFullscreen Role = iota + Minimise Role = iota + Zoom Role = iota + FullScreen Role = iota + NewFile Role = iota + Open Role = iota + Save Role = iota + SaveAs Role = iota + StartSpeaking Role = iota + StopSpeaking Role = iota + Revert Role = iota + Print Role = iota + PageLayout Role = iota + Find Role = iota + FindAndReplace Role = iota + FindNext Role = iota + FindPrevious Role = iota + Front Role = iota + Help Role = iota +) + +// menuItemType classifies what kind of item a MenuItem is. +type menuItemType int + +const ( + menuItemTypeText menuItemType = iota + menuItemTypeSeparator menuItemType = iota + menuItemTypeCheckbox menuItemType = iota + menuItemTypeRadio menuItemType = iota + menuItemTypeSubmenu menuItemType = iota +) + +var globalMenuItemID uintptr + +// MenuItem is a node in a menu tree. +// +// item := NewMenuItem("Preferences"). +// SetAccelerator("CmdOrCtrl+,"). +// OnClick(func(ctx *Context) { openPrefs() }) +type MenuItem struct { + id uint + label string + tooltip string + disabled bool + checked bool + hidden bool + bitmap []byte + submenu *Menu + callback func(*Context) + itemType menuItemType + acceleratorStr string + role Role + contextMenuData *ContextMenuData + + radioGroupMembers []*MenuItem +} + +func nextMenuItemID() uint { + return uint(atomic.AddUintptr(&globalMenuItemID, 1)) +} + +// NewMenuItem creates a standard clickable menu item with the given label. +// +// item := NewMenuItem("Save").OnClick(func(ctx *Context) { save() }) +func NewMenuItem(label string) *MenuItem { + return &MenuItem{ + id: nextMenuItemID(), + label: label, + itemType: menuItemTypeText, + disabled: false, + } +} + +// NewMenuItemSeparator creates a horizontal separator. +// +// menu.AppendItem(NewMenuItemSeparator()) +func NewMenuItemSeparator() *MenuItem { + return &MenuItem{ + id: nextMenuItemID(), + itemType: menuItemTypeSeparator, + } +} + +// NewMenuItemCheckbox creates a checkable menu item. +// +// item := NewMenuItemCheckbox("Show Toolbar", true) +func NewMenuItemCheckbox(label string, checked bool) *MenuItem { + return &MenuItem{ + id: nextMenuItemID(), + label: label, + checked: checked, + itemType: menuItemTypeCheckbox, + } +} + +// NewMenuItemRadio creates a radio-group menu item. +// +// light := NewMenuItemRadio("Light Theme", true) +func NewMenuItemRadio(label string, checked bool) *MenuItem { + return &MenuItem{ + id: nextMenuItemID(), + label: label, + checked: checked, + itemType: menuItemTypeRadio, + } +} + +// NewSubMenuItem creates an item that reveals a child menu on hover. +// +// sub := NewSubMenuItem("Recent Files") +// sub.GetSubmenu().Add("report.pdf") +func NewSubMenuItem(label string) *MenuItem { + return &MenuItem{ + id: nextMenuItemID(), + label: label, + itemType: menuItemTypeSubmenu, + submenu: &Menu{label: label}, + } +} + +// NewRole creates a platform-managed menu item for the given role. +// +// menu.AppendItem(NewRole(Quit)) +func NewRole(role Role) *MenuItem { + item := &MenuItem{ + id: nextMenuItemID(), + label: roleLabel(role), + itemType: menuItemTypeText, + role: role, + } + return item +} + +func roleLabel(role Role) string { + switch role { + case AppMenu: + return "Application" + case EditMenu: + return "Edit" + case ViewMenu: + return "View" + case WindowMenu: + return "Window" + case ServicesMenu: + return "Services" + case HelpMenu: + return "Help" + case SpeechMenu: + return "Speech" + case FileMenu: + return "File" + case Hide: + return "Hide" + case HideOthers: + return "Hide Others" + case ShowAll: + return "Show All" + case BringAllToFront: + return "Bring All to Front" + case UnHide: + return "Unhide" + case About: + return "About" + case Undo: + return "Undo" + case Redo: + return "Redo" + case Cut: + return "Cut" + case Copy: + return "Copy" + case Paste: + return "Paste" + case PasteAndMatchStyle: + return "Paste and Match Style" + case SelectAll: + return "Select All" + case Delete: + return "Delete" + case Quit: + return "Quit" + case CloseWindow: + return "Close Window" + case Reload: + return "Reload" + case ForceReload: + return "Force Reload" + case OpenDevTools: + return "Open Dev Tools" + case ResetZoom: + return "Reset Zoom" + case ZoomIn: + return "Zoom In" + case ZoomOut: + return "Zoom Out" + case ToggleFullscreen: + return "Toggle Fullscreen" + case Minimise: + return "Minimise" + case Zoom: + return "Zoom" + case FullScreen: + return "Fullscreen" + case NewFile: + return "New" + case Open: + return "Open" + case Save: + return "Save" + case SaveAs: + return "Save As" + case StartSpeaking: + return "Start Speaking" + case StopSpeaking: + return "Stop Speaking" + case Revert: + return "Revert" + case Print: + return "Print" + case PageLayout: + return "Page Layout" + case Find: + return "Find" + case FindAndReplace: + return "Find and Replace" + case FindNext: + return "Find Next" + case FindPrevious: + return "Find Previous" + case Front: + return "Bring All to Front" + case Help: + return "Help" + default: + return "" + } +} + +// Fluent setters — all return *MenuItem for chaining. + +// SetLabel updates the visible label. +func (m *MenuItem) SetLabel(s string) *MenuItem { m.label = s; return m } + +// SetAccelerator sets the keyboard shortcut string (e.g. "CmdOrCtrl+S"). +func (m *MenuItem) SetAccelerator(shortcut string) *MenuItem { + m.acceleratorStr = shortcut + return m +} + +// GetAccelerator returns the raw accelerator string. +func (m *MenuItem) GetAccelerator() string { return m.acceleratorStr } + +// RemoveAccelerator clears the keyboard shortcut. +func (m *MenuItem) RemoveAccelerator() { m.acceleratorStr = "" } + +// SetTooltip sets the hover tooltip. +func (m *MenuItem) SetTooltip(s string) *MenuItem { m.tooltip = s; return m } + +// SetRole assigns a platform role to the item. +func (m *MenuItem) SetRole(role Role) *MenuItem { m.role = role; return m } + +// SetEnabled enables or disables the item. +func (m *MenuItem) SetEnabled(enabled bool) *MenuItem { m.disabled = !enabled; return m } + +// SetBitmap sets a raw-bytes icon for the item (Windows). +func (m *MenuItem) SetBitmap(bitmap []byte) *MenuItem { + m.bitmap = append([]byte(nil), bitmap...) + return m +} + +// SetChecked sets the checked state for checkbox/radio items. +func (m *MenuItem) SetChecked(checked bool) *MenuItem { m.checked = checked; return m } + +// SetHidden hides or shows the item without removing it. +func (m *MenuItem) SetHidden(hidden bool) *MenuItem { m.hidden = hidden; return m } + +// OnClick registers the callback invoked when the item is activated. +func (m *MenuItem) OnClick(f func(*Context)) *MenuItem { m.callback = f; return m } + +// Getters + +// Label returns the item's display label. +func (m *MenuItem) Label() string { return m.label } + +// Tooltip returns the hover tooltip. +func (m *MenuItem) Tooltip() string { return m.tooltip } + +// Enabled reports whether the item is interactive. +func (m *MenuItem) Enabled() bool { return !m.disabled } + +// Checked reports whether the item is checked. +func (m *MenuItem) Checked() bool { return m.checked } + +// Hidden reports whether the item is hidden. +func (m *MenuItem) Hidden() bool { return m.hidden } + +// IsSeparator reports whether this is a separator item. +func (m *MenuItem) IsSeparator() bool { return m.itemType == menuItemTypeSeparator } + +// IsSubmenu reports whether this item contains a child menu. +func (m *MenuItem) IsSubmenu() bool { return m.itemType == menuItemTypeSubmenu } + +// IsCheckbox reports whether this is a checkbox item. +func (m *MenuItem) IsCheckbox() bool { return m.itemType == menuItemTypeCheckbox } + +// IsRadio reports whether this is a radio item. +func (m *MenuItem) IsRadio() bool { return m.itemType == menuItemTypeRadio } + +// GetSubmenu returns the submenu, or nil if this is not a submenu item. +func (m *MenuItem) GetSubmenu() *Menu { return m.submenu } + +// Clone returns a shallow copy of the MenuItem. +func (m *MenuItem) Clone() *MenuItem { + cloned := *m + if m.submenu != nil { + cloned.submenu = m.submenu.Clone() + } + if m.bitmap != nil { + cloned.bitmap = append([]byte(nil), m.bitmap...) + } + if m.contextMenuData != nil { + cloned.contextMenuData = m.contextMenuData.clone() + } + cloned.radioGroupMembers = nil + return &cloned +} + +// Destroy frees resources held by the item and its submenu. +func (m *MenuItem) Destroy() { + if m.submenu != nil { + m.submenu.Destroy() + m.submenu = nil + } + m.callback = nil + m.radioGroupMembers = nil + m.contextMenuData = nil +} diff --git a/stubs/wails/pkg/application/screen.go b/stubs/wails/pkg/application/screen.go new file mode 100644 index 00000000..142abd43 --- /dev/null +++ b/stubs/wails/pkg/application/screen.go @@ -0,0 +1,202 @@ +package application + +import ( + "math" + "sync" +) + +// Rect describes an axis-aligned rectangle. +// r := Rect{X: 0, Y: 0, Width: 1920, Height: 1080} +type Rect struct { + X int + Y int + Width int + Height int +} + +// Point is a 2D integer coordinate. +// p := Point{X: 100, Y: 200} +type Point struct { + X int + Y int +} + +// Size holds a width/height pair. +// s := Size{Width: 1920, Height: 1080} +type Size struct { + Width int + Height int +} + +// Origin returns the top-left corner of the rect as a Point. +// origin := r.Origin() +func (r Rect) Origin() Point { return Point{X: r.X, Y: r.Y} } + +// Corner returns the exclusive bottom-right corner of the rect. +// corner := r.Corner() +func (r Rect) Corner() Point { return Point{X: r.X + r.Width, Y: r.Y + r.Height} } + +// InsideCorner returns the last pixel inside the rect. +// inside := r.InsideCorner() +func (r Rect) InsideCorner() Point { return Point{X: r.X + r.Width - 1, Y: r.Y + r.Height - 1} } + +// RectSize returns the size of the rect. +// s := r.RectSize() +func (r Rect) RectSize() Size { return Size{Width: r.Width, Height: r.Height} } + +// IsEmpty reports whether the rect has no area. +// if r.IsEmpty() { return } +func (r Rect) IsEmpty() bool { return r.Width <= 0 || r.Height <= 0 } + +// Contains reports whether the point lies inside the rect. +// if r.Contains(Point{X: 100, Y: 200}) { ... } +func (r Rect) Contains(point Point) bool { + return point.X >= r.X && point.X < r.X+r.Width && + point.Y >= r.Y && point.Y < r.Y+r.Height +} + +// Intersect returns the overlapping rectangle of r and other. +// overlap := r.Intersect(otherRect) +func (r Rect) Intersect(other Rect) Rect { + if r.IsEmpty() || other.IsEmpty() { + return Rect{} + } + maxLeft := max(r.X, other.X) + maxTop := max(r.Y, other.Y) + minRight := min(r.X+r.Width, other.X+other.Width) + minBottom := min(r.Y+r.Height, other.Y+other.Height) + if minRight > maxLeft && minBottom > maxTop { + return Rect{X: maxLeft, Y: maxTop, Width: minRight - maxLeft, Height: minBottom - maxTop} + } + return Rect{} +} + +// Screen describes a physical display. +// primary := manager.GetPrimary() +type Screen struct { + ID string + Name string + ScaleFactor float32 + X int + Y int + Size Size + Bounds Rect + PhysicalBounds Rect + WorkArea Rect + PhysicalWorkArea Rect + IsPrimary bool + Rotation float32 +} + +// Origin returns the logical origin of the screen. +// origin := screen.Origin() +func (s Screen) Origin() Point { return Point{X: s.X, Y: s.Y} } + +// scale converts a value between physical and DIP coordinates for this screen. +func (s Screen) scale(value int, toDIP bool) int { + if toDIP { + return int(math.Ceil(float64(value) / float64(s.ScaleFactor))) + } + return int(math.Floor(float64(value) * float64(s.ScaleFactor))) +} + +// ScreenManager holds the list of known screens in memory. +// screens := manager.GetAll() +type ScreenManager struct { + mu sync.RWMutex + screens []*Screen + primaryScreen *Screen +} + +// NewScreenManager constructs an empty ScreenManager. +// manager := NewScreenManager() +func NewScreenManager() *ScreenManager { + return &ScreenManager{} +} + +// SetScreens replaces the tracked screen list; the first screen with IsPrimary == true becomes primary. +// manager.SetScreens([]*Screen{primary, secondary}) +func (m *ScreenManager) SetScreens(screens []*Screen) { + m.mu.Lock() + defer m.mu.Unlock() + m.screens = screens + m.primaryScreen = nil + for _, screen := range screens { + if screen.IsPrimary { + m.primaryScreen = screen + break + } + } +} + +// GetAll returns all tracked screens. +// for _, s := range manager.GetAll() { fmt.Println(s.Name) } +func (m *ScreenManager) GetAll() []*Screen { + m.mu.RLock() + defer m.mu.RUnlock() + result := make([]*Screen, len(m.screens)) + copy(result, m.screens) + return result +} + +// GetPrimary returns the primary screen, or nil if none is set. +// primary := manager.GetPrimary() +func (m *ScreenManager) GetPrimary() *Screen { + m.mu.RLock() + defer m.mu.RUnlock() + return m.primaryScreen +} + +// GetCurrent returns the screen nearest to the given DIP point. +// current := manager.GetCurrent(Point{X: 500, Y: 300}) +func (m *ScreenManager) GetCurrent(dipPoint Point) *Screen { + m.mu.RLock() + defer m.mu.RUnlock() + for _, screen := range m.screens { + if screen.Bounds.Contains(dipPoint) { + return screen + } + } + return m.primaryScreen +} + +// DipToPhysicalPoint converts a DIP point to physical pixels using the nearest screen. +// physical := manager.DipToPhysicalPoint(Point{X: 100, Y: 200}) +func (m *ScreenManager) DipToPhysicalPoint(dipPoint Point) Point { + screen := m.GetCurrent(dipPoint) + if screen == nil { + return dipPoint + } + relativeX := dipPoint.X - screen.Bounds.X + relativeY := dipPoint.Y - screen.Bounds.Y + return Point{ + X: screen.PhysicalBounds.X + screen.scale(relativeX, false), + Y: screen.PhysicalBounds.Y + screen.scale(relativeY, false), + } +} + +// PhysicalToDipPoint converts physical pixel coordinates to DIP using the nearest screen. +// dip := manager.PhysicalToDipPoint(Point{X: 200, Y: 400}) +func (m *ScreenManager) PhysicalToDipPoint(physicalPoint Point) Point { + m.mu.RLock() + var nearest *Screen + for _, screen := range m.screens { + if screen.PhysicalBounds.Contains(physicalPoint) { + nearest = screen + break + } + } + if nearest == nil { + nearest = m.primaryScreen + } + m.mu.RUnlock() + if nearest == nil { + return physicalPoint + } + relativeX := physicalPoint.X - nearest.PhysicalBounds.X + relativeY := physicalPoint.Y - nearest.PhysicalBounds.Y + return Point{ + X: nearest.Bounds.X + nearest.scale(relativeX, true), + Y: nearest.Bounds.Y + nearest.scale(relativeY, true), + } +} diff --git a/stubs/wails/pkg/application/services.go b/stubs/wails/pkg/application/services.go new file mode 100644 index 00000000..e3546704 --- /dev/null +++ b/stubs/wails/pkg/application/services.go @@ -0,0 +1,76 @@ +package application + +import "context" + +// Service wraps a bound type instance for registration with the application. +// svc := NewService(&MyService{}) +type Service struct { + instance any + options ServiceOptions +} + +// ServiceOptions provides optional configuration for a Service. +// opts := ServiceOptions{Name: "my-service", Route: "/api/my"} +type ServiceOptions struct { + // Name overrides the service name used in logging and debugging. + // Defaults to the value from ServiceName interface, or the type name. + Name string + + // Route mounts the service on the internal asset server at this path + // when the instance implements http.Handler. + Route string + + // MarshalError serialises errors returned by service methods to JSON. + // Return nil to fall back to the globally configured error handler. + MarshalError func(error) []byte +} + +// DefaultServiceOptions is the default service options used by NewService. +var DefaultServiceOptions = ServiceOptions{} + +// NewService wraps instance as a Service. +// svc := NewService(&Calculator{}) +func NewService[T any](instance *T) Service { + return Service{instance: instance, options: DefaultServiceOptions} +} + +// NewServiceWithOptions wraps instance as a Service with explicit options. +// svc := NewServiceWithOptions(&Calculator{}, ServiceOptions{Name: "calculator"}) +func NewServiceWithOptions[T any](instance *T, options ServiceOptions) Service { + service := NewService(instance) + service.options = options + return service +} + +// Instance returns the underlying pointer passed to NewService. +// raw := svc.Instance().(*Calculator) +func (s Service) Instance() any { + return s.instance +} + +// Options returns the ServiceOptions for this service. +// opts := svc.Options() +func (s Service) Options() ServiceOptions { + return s.options +} + +// ServiceName is an optional interface that service instances may implement +// to provide a human-readable name for logging and debugging. +// func (s *MyService) ServiceName() string { return "my-service" } +type ServiceName interface { + ServiceName() string +} + +// ServiceStartup is an optional interface for services that need initialisation. +// Called in registration order during application startup. +// func (s *MyService) ServiceStartup(ctx context.Context, opts ServiceOptions) error { ... } +type ServiceStartup interface { + ServiceStartup(ctx context.Context, options ServiceOptions) error +} + +// ServiceShutdown is an optional interface for services that need cleanup. +// Called in reverse registration order during application shutdown. +// func (s *MyService) ServiceShutdown() error { ... } +type ServiceShutdown interface { + ServiceShutdown() error +} diff --git a/stubs/wails/pkg/application/webview_window_options.go b/stubs/wails/pkg/application/webview_window_options.go new file mode 100644 index 00000000..eb769736 --- /dev/null +++ b/stubs/wails/pkg/application/webview_window_options.go @@ -0,0 +1,471 @@ +package application + +import "github.com/wailsapp/wails/v3/pkg/events" + +// WindowState represents the starting visual state of a window. +type WindowState int + +const ( + WindowStateNormal WindowState = iota + WindowStateMinimised WindowState = iota + WindowStateMaximised WindowState = iota + WindowStateFullscreen WindowState = iota +) + +// WindowStartPosition determines how the initial X/Y coordinates are interpreted. +type WindowStartPosition int + +const ( + // WindowCentered places the window in the centre of the screen on first show. + WindowCentered WindowStartPosition = 0 + // WindowXY places the window at the explicit X/Y coordinates. + WindowXY WindowStartPosition = 1 +) + +// BackgroundType controls window transparency. +type BackgroundType int + +const ( + BackgroundTypeSolid BackgroundType = iota + BackgroundTypeTransparent BackgroundType = iota + BackgroundTypeTranslucent BackgroundType = iota +) + +// WebviewWindowOptions holds all configuration for a webview window. +// +// opts := WebviewWindowOptions{ +// Name: "main", +// Title: "My App", +// Width: 1280, +// Height: 800, +// Mac: MacWindow{TitleBar: MacTitleBarHiddenInset}, +// } +type WebviewWindowOptions struct { + // Name is a unique identifier for the window. + Name string + // Title is shown in the title bar. + Title string + // Width is the initial width in logical pixels. + Width int + // Height is the initial height in logical pixels. + Height int + // AlwaysOnTop makes the window float above others. + AlwaysOnTop bool + // URL is the URL to load on creation. + URL string + // HTML is inline HTML to load (alternative to URL). + HTML string + // JS is inline JavaScript to inject. + JS string + // CSS is inline CSS to inject. + CSS string + // DisableResize prevents the user from resizing the window. + DisableResize bool + // Frameless removes the OS window frame. + Frameless bool + // MinWidth is the minimum allowed width. + MinWidth int + // MinHeight is the minimum allowed height. + MinHeight int + // MaxWidth is the maximum allowed width (0 = unlimited). + MaxWidth int + // MaxHeight is the maximum allowed height (0 = unlimited). + MaxHeight int + // StartState sets the visual state when first shown. + StartState WindowState + // BackgroundType controls transparency. + BackgroundType BackgroundType + // BackgroundColour is the solid background fill colour. + BackgroundColour RGBA + // InitialPosition controls how X/Y are interpreted. + InitialPosition WindowStartPosition + // X is the initial horizontal position. + X int + // Y is the initial vertical position. + Y int + // Hidden creates the window without showing it. + Hidden bool + // Zoom sets the initial zoom magnification (0 = default = 1.0). + Zoom float64 + // ZoomControlEnabled allows the user to change zoom. + ZoomControlEnabled bool + // EnableFileDrop enables drag-and-drop of files onto the window. + EnableFileDrop bool + // OpenInspectorOnStartup opens the web inspector when first shown. + OpenInspectorOnStartup bool + // DevToolsEnabled exposes the developer tools (default true in debug builds). + DevToolsEnabled bool + // DefaultContextMenuDisabled disables the built-in right-click menu. + DefaultContextMenuDisabled bool + // KeyBindings is a map of accelerator strings to window callbacks. + KeyBindings map[string]func(window Window) + // IgnoreMouseEvents passes all mouse events through (Windows + macOS only). + IgnoreMouseEvents bool + // ContentProtectionEnabled prevents screen capture (Windows + macOS only). + ContentProtectionEnabled bool + // HideOnFocusLost hides the window when it loses focus. + HideOnFocusLost bool + // HideOnEscape hides the window when Escape is pressed. + HideOnEscape bool + // UseApplicationMenu uses the application-level menu for this window. + UseApplicationMenu bool + // MinimiseButtonState controls the minimise button state. + MinimiseButtonState ButtonState + // MaximiseButtonState controls the maximise/zoom button state. + MaximiseButtonState ButtonState + // CloseButtonState controls the close button state. + CloseButtonState ButtonState + // Mac contains macOS-specific window options. + Mac MacWindow + // Windows contains Windows-specific window options. + Windows WindowsWindow + // Linux contains Linux-specific window options. + Linux LinuxWindow +} + +// ------------------------- +// macOS-specific types +// ------------------------- + +// MacBackdrop is the backdrop material for a macOS window. +type MacBackdrop int + +const ( + // MacBackdropNormal uses an opaque background. + MacBackdropNormal MacBackdrop = iota + // MacBackdropTransparent shows the desktop behind the window. + MacBackdropTransparent MacBackdrop = iota + // MacBackdropTranslucent applies a frosted-glass vibrancy effect. + MacBackdropTranslucent MacBackdrop = iota + // MacBackdropLiquidGlass uses the Apple Liquid Glass effect (macOS 15+). + MacBackdropLiquidGlass MacBackdrop = iota +) + +// MacToolbarStyle controls toolbar layout relative to the title bar. +type MacToolbarStyle int + +const ( + MacToolbarStyleAutomatic MacToolbarStyle = iota + MacToolbarStyleExpanded MacToolbarStyle = iota + MacToolbarStylePreference MacToolbarStyle = iota + MacToolbarStyleUnified MacToolbarStyle = iota + MacToolbarStyleUnifiedCompact MacToolbarStyle = iota +) + +// MacLiquidGlassStyle defines the tint of the Liquid Glass effect. +type MacLiquidGlassStyle int + +const ( + LiquidGlassStyleAutomatic MacLiquidGlassStyle = iota + LiquidGlassStyleLight MacLiquidGlassStyle = iota + LiquidGlassStyleDark MacLiquidGlassStyle = iota + LiquidGlassStyleVibrant MacLiquidGlassStyle = iota +) + +// NSVisualEffectMaterial maps to the NSVisualEffectMaterial macOS enum. +type NSVisualEffectMaterial int + +const ( + NSVisualEffectMaterialAppearanceBased NSVisualEffectMaterial = 0 + NSVisualEffectMaterialLight NSVisualEffectMaterial = 1 + NSVisualEffectMaterialDark NSVisualEffectMaterial = 2 + NSVisualEffectMaterialTitlebar NSVisualEffectMaterial = 3 + NSVisualEffectMaterialSelection NSVisualEffectMaterial = 4 + NSVisualEffectMaterialMenu NSVisualEffectMaterial = 5 + NSVisualEffectMaterialPopover NSVisualEffectMaterial = 6 + NSVisualEffectMaterialSidebar NSVisualEffectMaterial = 7 + NSVisualEffectMaterialHeaderView NSVisualEffectMaterial = 10 + NSVisualEffectMaterialSheet NSVisualEffectMaterial = 11 + NSVisualEffectMaterialWindowBackground NSVisualEffectMaterial = 12 + NSVisualEffectMaterialHUDWindow NSVisualEffectMaterial = 13 + NSVisualEffectMaterialFullScreenUI NSVisualEffectMaterial = 15 + NSVisualEffectMaterialToolTip NSVisualEffectMaterial = 17 + NSVisualEffectMaterialContentBackground NSVisualEffectMaterial = 18 + NSVisualEffectMaterialUnderWindowBackground NSVisualEffectMaterial = 21 + NSVisualEffectMaterialUnderPageBackground NSVisualEffectMaterial = 22 + NSVisualEffectMaterialAuto NSVisualEffectMaterial = -1 +) + +// MacLiquidGlass configures the Liquid Glass compositor effect. +type MacLiquidGlass struct { + Style MacLiquidGlassStyle + Material NSVisualEffectMaterial + CornerRadius float64 + TintColor *RGBA + GroupID string + GroupSpacing float64 +} + +// MacAppearanceType is the NSAppearance name string for a macOS window. +type MacAppearanceType string + +const ( + DefaultAppearance MacAppearanceType = "" + NSAppearanceNameAqua MacAppearanceType = "NSAppearanceNameAqua" + NSAppearanceNameDarkAqua MacAppearanceType = "NSAppearanceNameDarkAqua" + NSAppearanceNameVibrantLight MacAppearanceType = "NSAppearanceNameVibrantLight" + NSAppearanceNameAccessibilityHighContrastAqua MacAppearanceType = "NSAppearanceNameAccessibilityHighContrastAqua" + NSAppearanceNameAccessibilityHighContrastDarkAqua MacAppearanceType = "NSAppearanceNameAccessibilityHighContrastDarkAqua" + NSAppearanceNameAccessibilityHighContrastVibrantLight MacAppearanceType = "NSAppearanceNameAccessibilityHighContrastVibrantLight" + NSAppearanceNameAccessibilityHighContrastVibrantDark MacAppearanceType = "NSAppearanceNameAccessibilityHighContrastVibrantDark" +) + +// MacWindowLevel controls Z-ordering relative to other windows. +type MacWindowLevel string + +const ( + MacWindowLevelNormal MacWindowLevel = "normal" + MacWindowLevelFloating MacWindowLevel = "floating" + MacWindowLevelTornOffMenu MacWindowLevel = "tornOffMenu" + MacWindowLevelModalPanel MacWindowLevel = "modalPanel" + MacWindowLevelMainMenu MacWindowLevel = "mainMenu" + MacWindowLevelStatus MacWindowLevel = "status" + MacWindowLevelPopUpMenu MacWindowLevel = "popUpMenu" + MacWindowLevelScreenSaver MacWindowLevel = "screenSaver" +) + +// MacWindowCollectionBehavior is a bitmask controlling Spaces and fullscreen behaviour. +type MacWindowCollectionBehavior int + +const ( + MacWindowCollectionBehaviorDefault MacWindowCollectionBehavior = 0 + MacWindowCollectionBehaviorCanJoinAllSpaces MacWindowCollectionBehavior = 1 << 0 + MacWindowCollectionBehaviorMoveToActiveSpace MacWindowCollectionBehavior = 1 << 1 + MacWindowCollectionBehaviorManaged MacWindowCollectionBehavior = 1 << 2 + MacWindowCollectionBehaviorTransient MacWindowCollectionBehavior = 1 << 3 + MacWindowCollectionBehaviorStationary MacWindowCollectionBehavior = 1 << 4 + MacWindowCollectionBehaviorParticipatesInCycle MacWindowCollectionBehavior = 1 << 5 + MacWindowCollectionBehaviorIgnoresCycle MacWindowCollectionBehavior = 1 << 6 + MacWindowCollectionBehaviorFullScreenPrimary MacWindowCollectionBehavior = 1 << 7 + MacWindowCollectionBehaviorFullScreenAuxiliary MacWindowCollectionBehavior = 1 << 8 + MacWindowCollectionBehaviorFullScreenNone MacWindowCollectionBehavior = 1 << 9 + MacWindowCollectionBehaviorFullScreenAllowsTiling MacWindowCollectionBehavior = 1 << 11 + MacWindowCollectionBehaviorFullScreenDisallowsTiling MacWindowCollectionBehavior = 1 << 12 +) + +// MacWebviewPreferences holds WKWebView preference flags for macOS. +// Use integer tristate: 0 = unset, 1 = true, 2 = false. +type MacWebviewPreferences struct { + TabFocusesLinks int + TextInteractionEnabled int + FullscreenEnabled int + AllowsBackForwardNavigationGestures int +} + +// MacTitleBar configures the macOS title bar appearance. +type MacTitleBar struct { + // AppearsTransparent removes the title bar background. + AppearsTransparent bool + // Hide removes the title bar entirely. + Hide bool + // HideTitle hides only the text title. + HideTitle bool + // FullSizeContent extends window content into the title bar area. + FullSizeContent bool + // UseToolbar replaces the title bar with an NSToolbar. + UseToolbar bool + // HideToolbarSeparator removes the line between toolbar and content. + HideToolbarSeparator bool + // ShowToolbarWhenFullscreen keeps the toolbar visible in fullscreen. + ShowToolbarWhenFullscreen bool + // ToolbarStyle selects the toolbar layout style. + ToolbarStyle MacToolbarStyle +} + +// MacWindow contains macOS-specific window options. +type MacWindow struct { + Backdrop MacBackdrop + DisableShadow bool + TitleBar MacTitleBar + Appearance MacAppearanceType + InvisibleTitleBarHeight int + EventMapping map[events.WindowEventType]events.WindowEventType + EnableFraudulentWebsiteWarnings bool + WebviewPreferences MacWebviewPreferences + WindowLevel MacWindowLevel + CollectionBehavior MacWindowCollectionBehavior + LiquidGlass MacLiquidGlass +} + +// Pre-built MacTitleBar configurations — use directly in MacWindow.TitleBar. + +// MacTitleBarDefault produces the standard macOS title bar. +// +// Mac: MacWindow{TitleBar: MacTitleBarDefault} +var MacTitleBarDefault = MacTitleBar{} + +// MacTitleBarHidden hides the title text while keeping the traffic-light buttons. +// +// Mac: MacWindow{TitleBar: MacTitleBarHidden} +var MacTitleBarHidden = MacTitleBar{ + AppearsTransparent: true, + HideTitle: true, + FullSizeContent: true, +} + +// MacTitleBarHiddenInset keeps traffic lights slightly inset from the window edge. +// +// Mac: MacWindow{TitleBar: MacTitleBarHiddenInset} +var MacTitleBarHiddenInset = MacTitleBar{ + AppearsTransparent: true, + HideTitle: true, + FullSizeContent: true, + UseToolbar: true, + HideToolbarSeparator: true, +} + +// MacTitleBarHiddenInsetUnified uses the unified toolbar style for a more compact look. +// +// Mac: MacWindow{TitleBar: MacTitleBarHiddenInsetUnified} +var MacTitleBarHiddenInsetUnified = MacTitleBar{ + AppearsTransparent: true, + HideTitle: true, + FullSizeContent: true, + UseToolbar: true, + HideToolbarSeparator: true, + ToolbarStyle: MacToolbarStyleUnified, +} + +// ------------------------- +// Windows-specific types +// ------------------------- + +// BackdropType selects the Windows 11 compositor effect. +type BackdropType int32 + +const ( + Auto BackdropType = 0 + None BackdropType = 1 + Mica BackdropType = 2 + Acrylic BackdropType = 3 + Tabbed BackdropType = 4 +) + +// Theme selects dark or light mode on Windows. +type Theme int + +const ( + SystemDefault Theme = 0 + Dark Theme = 1 + Light Theme = 2 +) + +// WindowTheme holds custom title-bar colours for a Windows window. +type WindowTheme struct { + BorderColour *uint32 + TitleBarColour *uint32 + TitleTextColour *uint32 +} + +// TextTheme holds foreground/background colour pair for menu text. +type TextTheme struct { + Text *uint32 + Background *uint32 +} + +// MenuBarTheme holds per-state text themes for the Windows menu bar. +type MenuBarTheme struct { + Default *TextTheme + Hover *TextTheme + Selected *TextTheme +} + +// ThemeSettings holds custom colours for dark/light mode on Windows. +// Colours use 0x00BBGGRR encoding. +type ThemeSettings struct { + DarkModeActive *WindowTheme + DarkModeInactive *WindowTheme + LightModeActive *WindowTheme + LightModeInactive *WindowTheme + DarkModeMenuBar *MenuBarTheme + LightModeMenuBar *MenuBarTheme +} + +// CoreWebView2PermissionKind identifies a WebView2 permission category. +type CoreWebView2PermissionKind uint32 + +const ( + CoreWebView2PermissionKindUnknownPermission CoreWebView2PermissionKind = iota + CoreWebView2PermissionKindMicrophone + CoreWebView2PermissionKindCamera + CoreWebView2PermissionKindGeolocation + CoreWebView2PermissionKindNotifications + CoreWebView2PermissionKindOtherSensors + CoreWebView2PermissionKindClipboardRead +) + +// CoreWebView2PermissionState sets whether a permission is granted. +type CoreWebView2PermissionState uint32 + +const ( + CoreWebView2PermissionStateDefault CoreWebView2PermissionState = iota + CoreWebView2PermissionStateAllow + CoreWebView2PermissionStateDeny +) + +// WindowsWindow contains Windows-specific window options. +type WindowsWindow struct { + BackdropType BackdropType + DisableIcon bool + Theme Theme + CustomTheme ThemeSettings + DisableFramelessWindowDecorations bool + WindowMask []byte + WindowMaskDraggable bool + ResizeDebounceMS uint16 + WindowDidMoveDebounceMS uint16 + EventMapping map[events.WindowEventType]events.WindowEventType + HiddenOnTaskbar bool + EnableSwipeGestures bool + Menu *Menu + Permissions map[CoreWebView2PermissionKind]CoreWebView2PermissionState + ExStyle int + GeneralAutofillEnabled bool + PasswordAutosaveEnabled bool +} + +// ------------------------- +// Linux-specific types +// ------------------------- + +// WebviewGpuPolicy controls GPU acceleration for the Linux webview. +type WebviewGpuPolicy int + +const ( + WebviewGpuPolicyAlways WebviewGpuPolicy = iota + WebviewGpuPolicyOnDemand WebviewGpuPolicy = iota + WebviewGpuPolicyNever WebviewGpuPolicy = iota +) + +// LinuxMenuStyle selects how the application menu is rendered on Linux. +type LinuxMenuStyle int + +const ( + LinuxMenuStyleMenuBar LinuxMenuStyle = iota + LinuxMenuStylePrimaryMenu LinuxMenuStyle = iota +) + +// LinuxWindow contains Linux-specific window options. +type LinuxWindow struct { + Icon []byte + WindowIsTranslucent bool + WebviewGpuPolicy WebviewGpuPolicy + WindowDidMoveDebounceMS uint16 + Menu *Menu + MenuStyle LinuxMenuStyle +} + +// NewRGB constructs an RGBA value with full opacity from RGB components. +// +// colour := NewRGB(255, 128, 0) +func NewRGB(red, green, blue uint8) RGBA { + return RGBA{Red: red, Green: green, Blue: blue, Alpha: 255} +} + +// NewRGBPtr encodes RGB as a packed uint32 pointer (0x00BBGGRR) for ThemeSettings. +// +// theme.BorderColour = NewRGBPtr(255, 0, 0) +func NewRGBPtr(red, green, blue uint8) *uint32 { + result := uint32(red) | uint32(green)<<8 | uint32(blue)<<16 + return &result +} diff --git a/stubs/wails/pkg/application/window.go b/stubs/wails/pkg/application/window.go new file mode 100644 index 00000000..13bb641a --- /dev/null +++ b/stubs/wails/pkg/application/window.go @@ -0,0 +1,160 @@ +package application + +import ( + "unsafe" + + "github.com/wailsapp/wails/v3/pkg/events" +) + +// Window is the interface satisfied by all window types in the application. +// Fluent mutating methods return Window so callers can chain calls: +// +// app.Window.NewWithOptions(opts).SetTitle("Main").Show() +type Window interface { + // Identity + ID() uint + Name() string + + // Visibility + Show() Window + Hide() Window + IsVisible() bool + + // Lifecycle + Close() + Focus() + Run() + + // Geometry + Center() + Position() (x int, y int) + RelativePosition() (x int, y int) + Size() (width int, height int) + Width() int + Height() int + Bounds() Rect + SetPosition(x, y int) + SetRelativePosition(x, y int) Window + SetSize(width, height int) Window + SetBounds(bounds Rect) + SetMaxSize(maxWidth, maxHeight int) Window + SetMinSize(minWidth, minHeight int) Window + EnableSizeConstraints() + DisableSizeConstraints() + Resizable() bool + SetResizable(b bool) Window + + // State + Maximise() Window + UnMaximise() + ToggleMaximise() + IsMaximised() bool + Minimise() Window + UnMinimise() + IsMinimised() bool + Fullscreen() Window + UnFullscreen() + ToggleFullscreen() + IsFullscreen() bool + Restore() + SnapAssist() + + // Title and content + SetTitle(title string) Window + SetURL(s string) Window + SetHTML(html string) Window + + // Titlebar buttons (macOS / Windows) + SetMinimiseButtonState(state ButtonState) Window + SetMaximiseButtonState(state ButtonState) Window + SetCloseButtonState(state ButtonState) Window + + // Menu bar + SetMenu(menu *Menu) + ShowMenuBar() + HideMenuBar() + ToggleMenuBar() + + // Appearance + SetBackgroundColour(colour RGBA) Window + SetAlwaysOnTop(b bool) Window + SetFrameless(frameless bool) Window + ToggleFrameless() + SetIgnoreMouseEvents(ignore bool) Window + IsIgnoreMouseEvents() bool + SetContentProtection(protection bool) Window + + // Zoom + GetZoom() float64 + SetZoom(magnification float64) Window + Zoom() + ZoomIn() + ZoomOut() + ZoomReset() Window + + // Border sizes (Windows) + GetBorderSizes() *LRTB + + // Screen + GetScreen() (*Screen, error) + + // JavaScript / events + ExecJS(js string) + EmitEvent(name string, data ...any) bool + DispatchWailsEvent(event *CustomEvent) + OnWindowEvent(eventType events.WindowEventType, callback func(event *WindowEvent)) func() + RegisterHook(eventType events.WindowEventType, callback func(event *WindowEvent)) func() + + // Drag-and-drop (internal message bus) + handleDragAndDropMessage(filenames []string, dropTarget *DropTargetDetails) + InitiateFrontendDropProcessing(filenames []string, x int, y int) + + // Message handling (internal) + HandleMessage(message string) + HandleWindowEvent(id uint) + HandleKeyEvent(acceleratorString string) + + // Context menu + OpenContextMenu(data *ContextMenuData) + + // Modal + AttachModal(modalWindow Window) + + // DevTools + OpenDevTools() + + // Print + Print() error + + // Flash (Windows taskbar flash) + Flash(enabled bool) + + // Focus tracking + IsFocused() bool + + // Native handle (platform-specific, use with care) + NativeWindow() unsafe.Pointer + + // Enabled state + SetEnabled(enabled bool) + + // Reload + Reload() + ForceReload() + + // Logging (routed to the application logger) + Info(message string, args ...any) + Error(message string, args ...any) + + // Internal hooks + shouldUnconditionallyClose() bool + + // Editing operations (routed to focused element) + cut() + copy() + paste() + undo() + redo() + delete() + selectAll() +} diff --git a/stubs/wails/pkg/events/events.go b/stubs/wails/pkg/events/events.go index 3f3204d6..80d67b1d 100644 --- a/stubs/wails/pkg/events/events.go +++ b/stubs/wails/pkg/events/events.go @@ -1,5 +1,11 @@ package events +// ApplicationEventType identifies a platform-level application event. +// Matches the type used by the real Wails v3 package. +// +// em.OnApplicationEvent(events.Common.ThemeChanged, handler) +type ApplicationEventType uint + // WindowEventType identifies a window event emitted by the application layer. type WindowEventType int @@ -14,17 +20,25 @@ const ( // Common matches the event namespace used by the real Wails package. var Common = struct { - WindowFocus WindowEventType - WindowLostFocus WindowEventType - WindowDidMove WindowEventType - WindowDidResize WindowEventType - WindowClosing WindowEventType - WindowFilesDropped WindowEventType + ApplicationOpenedWithFile ApplicationEventType + ApplicationStarted ApplicationEventType + ApplicationLaunchedWithUrl ApplicationEventType + ThemeChanged ApplicationEventType + WindowFocus WindowEventType + WindowLostFocus WindowEventType + WindowDidMove WindowEventType + WindowDidResize WindowEventType + WindowClosing WindowEventType + WindowFilesDropped WindowEventType }{ - WindowFocus: WindowFocus, - WindowLostFocus: WindowLostFocus, - WindowDidMove: WindowDidMove, - WindowDidResize: WindowDidResize, - WindowClosing: WindowClosing, - WindowFilesDropped: WindowFilesDropped, + ApplicationOpenedWithFile: 1024, + ApplicationStarted: 1025, + ApplicationLaunchedWithUrl: 1026, + ThemeChanged: 1027, + WindowFocus: WindowFocus, + WindowLostFocus: WindowLostFocus, + WindowDidMove: WindowDidMove, + WindowDidResize: WindowDidResize, + WindowClosing: WindowClosing, + WindowFilesDropped: WindowFilesDropped, } From a9b795f223d2b5a9b1df1fab81273d0b95cd4428 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 31 Mar 2026 17:42:09 +0100 Subject: [PATCH 11/12] feat: Wails v3 stub bridge + feature expansion + display bridge + MCP events Stubs (15 files, 479 exports): - All managers: Dialog, Event, Browser, Clipboard, ContextMenu, Environment, Screen, KeyBinding - Window interface (~50 methods), BrowserWindow, platform options (iOS/Android) - MenuItem (42 roles), WebviewWindowOptions (full platform types) - Wails v3 submodule pinned at alpha 74 New events package (17th package): - Custom event system bridged to Core IPC - TaskEmit, TaskOn, TaskOff, QueryListeners, ActionEventFired Feature expansions: - Window: zoom, content (SetURL/SetHTML/ExecJS), bounds, print, flash - Screen: QueryCurrent, ScreenPlacement, Rect geometry - Dialog: typed tasks, file options, Info/Question/Warning/Error - Keybinding: TaskProcess, ErrorNotRegistered - Notification: RevokePermission, RegisterCategory, action broadcasts - Dock: SetProgressBar, Bounce/StopBounce - Environment: HasFocusFollowsMouse - ContextMenu: QueryGetAll, TaskUpdate, TaskDestroy Display bridge: 5 new event types wired to WebSocket MCP: 4 event tools (emit, on, off, list) 17 packages build and test clean (1 flaky test ordering issue in window). Co-Authored-By: Claude Opus 4.6 (1M context) --- go.mod | 3 + go.sum | 6 + pkg/contextmenu/messages.go | 32 +++- pkg/contextmenu/service.go | 20 +++ pkg/dialog/messages.go | 58 +++++++ pkg/dialog/platform.go | 13 +- pkg/dialog/service.go | 52 ++++++ pkg/dialog/service_test.go | 134 +++++++++++++++ pkg/display/display.go | 42 ++++- pkg/display/display_test.go | 20 +-- pkg/display/events.go | 30 ++-- pkg/dock/messages.go | 24 +++ pkg/dock/platform.go | 25 ++- pkg/dock/service.go | 18 ++ pkg/dock/service_test.go | 172 ++++++++++++++++++- pkg/environment/messages.go | 5 + pkg/environment/platform.go | 1 + pkg/environment/service.go | 2 + pkg/environment/service_test.go | 1 + pkg/events/messages.go | 34 ++++ pkg/events/platform.go | 26 +++ pkg/events/register.go | 16 ++ pkg/events/service.go | 103 +++++++++++ pkg/events/service_test.go | 272 ++++++++++++++++++++++++++++++ pkg/keybinding/messages.go | 15 +- pkg/keybinding/platform.go | 5 + pkg/keybinding/service.go | 17 +- pkg/keybinding/service_test.go | 66 +++++++- pkg/mcp/subsystem.go | 1 + pkg/mcp/tools_events.go | 100 +++++++++++ pkg/notification/messages.go | 21 +++ pkg/notification/platform.go | 15 ++ pkg/notification/service.go | 4 + pkg/notification/service_test.go | 109 +++++++++++- pkg/screen/messages.go | 4 + pkg/screen/platform.go | 53 ++++++ pkg/screen/service.go | 2 + pkg/screen/service_test.go | 126 ++++++++++++++ pkg/window/messages.go | 46 +++++ pkg/window/mock_platform.go | 41 ++++- pkg/window/mock_test.go | 45 +++-- pkg/window/platform.go | 28 +++ pkg/window/service.go | 97 +++++++++++ pkg/window/service_screen_test.go | 4 + pkg/window/service_test.go | 200 ++++++++++++++++++++++ pkg/window/wails.go | 21 ++- 46 files changed, 2050 insertions(+), 79 deletions(-) create mode 100644 pkg/events/messages.go create mode 100644 pkg/events/platform.go create mode 100644 pkg/events/register.go create mode 100644 pkg/events/service.go create mode 100644 pkg/events/service_test.go create mode 100644 pkg/mcp/tools_events.go diff --git a/go.mod b/go.mod index 77122c43..83629370 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,9 @@ require ( replace github.com/wailsapp/wails/v3 => ./stubs/wails require ( + dappco.re/go/core v0.8.0-alpha.1 // indirect + dappco.re/go/core/io v0.2.0 // indirect + dappco.re/go/core/log v0.1.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect diff --git a/go.sum b/go.sum index 486e4fe4..cf4254d8 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,9 @@ +dappco.re/go/core v0.8.0-alpha.1 h1:gj7+Scv+L63Z7wMxbJYHhaRFkHJo2u4MMPuUSv/Dhtk= +dappco.re/go/core v0.8.0-alpha.1/go.mod h1:f2/tBZ3+3IqDrg2F5F598llv0nmb/4gJVCFzM5geE4A= +dappco.re/go/core/io v0.2.0 h1:zuudgIiTsQQ5ipVt97saWdGLROovbEB/zdVyy9/l+I4= +dappco.re/go/core/io v0.2.0/go.mod h1:1QnQV6X9LNgFKfm8SkOtR9LLaj3bDcsOIeJOOyjbL5E= +dappco.re/go/core/log v0.1.0 h1:pa71Vq2TD2aoEUQWFKwNcaJ3GBY8HbaNGqtE688Unyc= +dappco.re/go/core/log v0.1.0/go.mod h1:Nkqb8gsXhZAO8VLpx7B8i1iAmohhzqA20b9Zr8VUcJs= forge.lthn.ai/core/config v0.1.8 h1:xP2hys7T94QGVF/OTh84/Zr5Dm/dL/0vzjht8zi+LOg= forge.lthn.ai/core/config v0.1.8/go.mod h1:8epZrkwoCt+5ayrqdinOUU/+w6UoxOyv9ZrdgVOgYfQ= forge.lthn.ai/core/go v0.3.3 h1:kYYZ2nRYy0/Be3cyuLJspRjLqTMxpckVyhb/7Sw2gd0= diff --git a/pkg/contextmenu/messages.go b/pkg/contextmenu/messages.go index 32acb5e9..30cd6a42 100644 --- a/pkg/contextmenu/messages.go +++ b/pkg/contextmenu/messages.go @@ -1,28 +1,56 @@ package contextmenu -import "errors" +import core "dappco.re/go/core" -var ErrorMenuNotFound = errors.New("contextmenu: menu not found") +var ErrorMenuNotFound = core.E("contextmenu", "menu not found", nil) // QueryGet returns a named context menu definition. Result: *ContextMenuDef (nil if not found) +// +// result := c.QUERY(contextmenu.QueryGet{Name: "editor"}) type QueryGet struct { Name string `json:"name"` } // QueryList returns all registered context menus. Result: map[string]ContextMenuDef +// +// result := c.QUERY(contextmenu.QueryList{}) type QueryList struct{} +// QueryGetAll returns all context menus as a slice. Result: []ContextMenuDef +// +// result := c.QUERY(contextmenu.QueryGetAll{}) +type QueryGetAll struct{} + // TaskAdd registers a named context menu. Replaces if already exists. +// +// c.PERFORM(contextmenu.TaskAdd{Name: "editor", Menu: def}) type TaskAdd struct { Name string `json:"name"` Menu ContextMenuDef `json:"menu"` } // TaskRemove unregisters a context menu by name. Error: ErrorMenuNotFound if missing. +// +// c.PERFORM(contextmenu.TaskRemove{Name: "editor"}) type TaskRemove struct { Name string `json:"name"` } +// TaskUpdate replaces a context menu definition. Error: ErrorMenuNotFound if missing. +// +// c.PERFORM(contextmenu.TaskUpdate{Name: "editor", Menu: newDef}) +type TaskUpdate struct { + Name string `json:"name"` + Menu ContextMenuDef `json:"menu"` +} + +// TaskDestroy removes a context menu and releases resources. +// +// c.PERFORM(contextmenu.TaskDestroy{Name: "editor"}) +type TaskDestroy struct { + Name string `json:"name"` +} + // ActionItemClicked is broadcast when a context menu item is clicked. type ActionItemClicked struct { MenuName string `json:"menuName"` diff --git a/pkg/contextmenu/service.go b/pkg/contextmenu/service.go index f6d97e49..f1ded3f4 100644 --- a/pkg/contextmenu/service.go +++ b/pkg/contextmenu/service.go @@ -34,6 +34,12 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) { return s.queryGet(q), true, nil case QueryList: return s.queryList(), true, nil + case QueryGetAll: + menus := make([]ContextMenuDef, 0, len(s.registeredMenus)) + for _, menu := range s.registeredMenus { + menus = append(menus, menu) + } + return menus, true, nil default: return nil, false, nil } @@ -63,6 +69,20 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { return nil, true, s.taskAdd(t) case TaskRemove: return nil, true, s.taskRemove(t) + case TaskUpdate: + if _, exists := s.registeredMenus[t.Name]; !exists { + return nil, true, ErrorMenuNotFound + } + _ = s.platform.Remove(t.Name) + delete(s.registeredMenus, t.Name) + return nil, true, s.taskAdd(TaskAdd{Name: t.Name, Menu: t.Menu}) + case TaskDestroy: + if _, exists := s.registeredMenus[t.Name]; !exists { + return nil, true, ErrorMenuNotFound + } + _ = s.platform.Remove(t.Name) + delete(s.registeredMenus, t.Name) + return nil, true, nil default: return nil, false, nil } diff --git a/pkg/dialog/messages.go b/pkg/dialog/messages.go index c274f2c4..4cc05ef4 100644 --- a/pkg/dialog/messages.go +++ b/pkg/dialog/messages.go @@ -1,9 +1,67 @@ package dialog +// TaskOpenFile opens a file picker dialog. Result: []string (selected paths) type TaskOpenFile struct{ Options OpenFileOptions } +// TaskOpenFileWithOptions opens a file picker, applying caller-supplied options. Result: []string +// paths, _, err := c.PERFORM(TaskOpenFileWithOptions{Title: "Import", AllowMultiple: true}) +type TaskOpenFileWithOptions struct { + Title string + Directory string + Filename string + Filters []FileFilter + AllowMultiple bool + CanChooseDirectories bool + CanChooseFiles bool + ShowHiddenFiles bool +} + +// TaskSaveFile opens a save file dialog. Result: string (chosen path) type TaskSaveFile struct{ Options SaveFileOptions } +// TaskSaveFileWithOptions opens a save dialog with caller-supplied options. Result: string +// path, _, err := c.PERFORM(TaskSaveFileWithOptions{Title: "Export", Filename: "out.csv"}) +type TaskSaveFileWithOptions struct { + Title string + Directory string + Filename string + Filters []FileFilter +} + +// TaskOpenDirectory opens a directory picker. Result: string type TaskOpenDirectory struct{ Options OpenDirectoryOptions } +// TaskMessageDialog opens an arbitrary message dialog. Result: string (button clicked) type TaskMessageDialog struct{ Options MessageDialogOptions } + +// TaskInfo shows an informational dialog. Result: string (button clicked) +// result, _, err := c.PERFORM(TaskInfo{Title: "Done", Message: "File saved."}) +type TaskInfo struct { + Title string + Message string + Buttons []string +} + +// TaskQuestion shows a question dialog. Result: string (button clicked) +// result, _, err := c.PERFORM(TaskQuestion{Title: "Confirm", Message: "Delete?", Buttons: []string{"Yes","No"}}) +type TaskQuestion struct { + Title string + Message string + Buttons []string +} + +// TaskWarning shows a warning dialog. Result: string (button clicked) +// result, _, err := c.PERFORM(TaskWarning{Title: "Warning", Message: "File exists."}) +type TaskWarning struct { + Title string + Message string + Buttons []string +} + +// TaskError shows an error dialog. Result: string (button clicked) +// result, _, err := c.PERFORM(TaskError{Title: "Error", Message: "Write failed."}) +type TaskError struct { + Title string + Message string + Buttons []string +} diff --git a/pkg/dialog/platform.go b/pkg/dialog/platform.go index 10585a42..fac7d4df 100644 --- a/pkg/dialog/platform.go +++ b/pkg/dialog/platform.go @@ -21,11 +21,14 @@ const ( // OpenFileOptions contains options for the open file dialog. type OpenFileOptions struct { - Title string `json:"title,omitempty"` - Directory string `json:"directory,omitempty"` - Filename string `json:"filename,omitempty"` - Filters []FileFilter `json:"filters,omitempty"` - AllowMultiple bool `json:"allowMultiple,omitempty"` + Title string `json:"title,omitempty"` + Directory string `json:"directory,omitempty"` + Filename string `json:"filename,omitempty"` + Filters []FileFilter `json:"filters,omitempty"` + AllowMultiple bool `json:"allowMultiple,omitempty"` + CanChooseDirectories bool `json:"canChooseDirectories,omitempty"` + CanChooseFiles bool `json:"canChooseFiles,omitempty"` + ShowHiddenFiles bool `json:"showHiddenFiles,omitempty"` } // SaveFileOptions contains options for the save file dialog. diff --git a/pkg/dialog/service.go b/pkg/dialog/service.go index b9b23b55..ef213808 100644 --- a/pkg/dialog/service.go +++ b/pkg/dialog/service.go @@ -37,15 +37,67 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { case TaskOpenFile: paths, err := s.platform.OpenFile(t.Options) return paths, true, err + case TaskOpenFileWithOptions: + paths, err := s.platform.OpenFile(OpenFileOptions{ + Title: t.Title, + Directory: t.Directory, + Filename: t.Filename, + Filters: t.Filters, + AllowMultiple: t.AllowMultiple, + CanChooseDirectories: t.CanChooseDirectories, + CanChooseFiles: t.CanChooseFiles, + ShowHiddenFiles: t.ShowHiddenFiles, + }) + return paths, true, err case TaskSaveFile: path, err := s.platform.SaveFile(t.Options) return path, true, err + case TaskSaveFileWithOptions: + path, err := s.platform.SaveFile(SaveFileOptions{ + Title: t.Title, + Directory: t.Directory, + Filename: t.Filename, + Filters: t.Filters, + }) + return path, true, err case TaskOpenDirectory: path, err := s.platform.OpenDirectory(t.Options) return path, true, err case TaskMessageDialog: button, err := s.platform.MessageDialog(t.Options) return button, true, err + case TaskInfo: + button, err := s.platform.MessageDialog(MessageDialogOptions{ + Type: DialogInfo, + Title: t.Title, + Message: t.Message, + Buttons: t.Buttons, + }) + return button, true, err + case TaskQuestion: + button, err := s.platform.MessageDialog(MessageDialogOptions{ + Type: DialogQuestion, + Title: t.Title, + Message: t.Message, + Buttons: t.Buttons, + }) + return button, true, err + case TaskWarning: + button, err := s.platform.MessageDialog(MessageDialogOptions{ + Type: DialogWarning, + Title: t.Title, + Message: t.Message, + Buttons: t.Buttons, + }) + return button, true, err + case TaskError: + button, err := s.platform.MessageDialog(MessageDialogOptions{ + Type: DialogError, + Title: t.Title, + Message: t.Message, + Buttons: t.Buttons, + }) + return button, true, err default: return nil, false, nil } diff --git a/pkg/dialog/service_test.go b/pkg/dialog/service_test.go index de476da4..2be9053f 100644 --- a/pkg/dialog/service_test.go +++ b/pkg/dialog/service_test.go @@ -121,3 +121,137 @@ func TestTaskOpenFile_Bad(t *testing.T) { _, handled, _ := c.PERFORM(TaskOpenFile{}) assert.False(t, handled) } + +func TestTaskOpenFileWithOptions_Good(t *testing.T) { + mock, c := newTestService(t) + mock.openFilePaths = []string{"/docs/report.pdf"} + + result, handled, err := c.PERFORM(TaskOpenFileWithOptions{ + Title: "Select Document", + AllowMultiple: false, + ShowHiddenFiles: true, + CanChooseFiles: true, + }) + require.NoError(t, err) + assert.True(t, handled) + paths := result.([]string) + assert.Equal(t, []string{"/docs/report.pdf"}, paths) + assert.Equal(t, "Select Document", mock.lastOpenOpts.Title) + assert.True(t, mock.lastOpenOpts.ShowHiddenFiles) + assert.True(t, mock.lastOpenOpts.CanChooseFiles) +} + +func TestTaskOpenFileWithOptions_CanChooseDirectories_Good(t *testing.T) { + mock, c := newTestService(t) + mock.openFilePaths = []string{"/home/user/projects"} + + _, handled, err := c.PERFORM(TaskOpenFileWithOptions{ + Title: "Pick folder", + CanChooseDirectories: true, + CanChooseFiles: false, + }) + require.NoError(t, err) + assert.True(t, handled) + assert.True(t, mock.lastOpenOpts.CanChooseDirectories) + assert.False(t, mock.lastOpenOpts.CanChooseFiles) +} + +func TestTaskOpenFileWithOptions_Bad_NoService(t *testing.T) { + c, _ := core.New(core.WithServiceLock()) + _, handled, _ := c.PERFORM(TaskOpenFileWithOptions{}) + assert.False(t, handled) +} + +func TestTaskSaveFileWithOptions_Good(t *testing.T) { + mock, c := newTestService(t) + mock.saveFilePath = "/exports/data.csv" + + result, handled, err := c.PERFORM(TaskSaveFileWithOptions{ + Title: "Export CSV", + Filename: "data.csv", + }) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, "/exports/data.csv", result) + assert.Equal(t, "Export CSV", mock.lastSaveOpts.Title) + assert.Equal(t, "data.csv", mock.lastSaveOpts.Filename) +} + +func TestTaskSaveFileWithOptions_Bad_NoService(t *testing.T) { + c, _ := core.New(core.WithServiceLock()) + _, handled, _ := c.PERFORM(TaskSaveFileWithOptions{}) + assert.False(t, handled) +} + +func TestTaskInfo_Good(t *testing.T) { + mock, c := newTestService(t) + mock.messageButton = "OK" + + result, handled, err := c.PERFORM(TaskInfo{Title: "Done", Message: "Saved successfully."}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, "OK", result) + assert.Equal(t, DialogInfo, mock.lastMsgOpts.Type) + assert.Equal(t, "Done", mock.lastMsgOpts.Title) +} + +func TestTaskQuestion_Good(t *testing.T) { + mock, c := newTestService(t) + mock.messageButton = "Yes" + + result, handled, err := c.PERFORM(TaskQuestion{ + Title: "Confirm", + Message: "Delete this file?", + Buttons: []string{"Yes", "No"}, + }) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, "Yes", result) + assert.Equal(t, DialogQuestion, mock.lastMsgOpts.Type) +} + +func TestTaskWarning_Good(t *testing.T) { + mock, c := newTestService(t) + mock.messageButton = "OK" + + result, handled, err := c.PERFORM(TaskWarning{Title: "Warning", Message: "File exists."}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, "OK", result) + assert.Equal(t, DialogWarning, mock.lastMsgOpts.Type) +} + +func TestTaskError_Good(t *testing.T) { + mock, c := newTestService(t) + mock.messageButton = "OK" + + result, handled, err := c.PERFORM(TaskError{Title: "Error", Message: "Write failed."}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, "OK", result) + assert.Equal(t, DialogError, mock.lastMsgOpts.Type) +} + +func TestTaskInfo_Bad_NoService(t *testing.T) { + c, _ := core.New(core.WithServiceLock()) + _, handled, _ := c.PERFORM(TaskInfo{}) + assert.False(t, handled) +} + +func TestTaskQuestion_Bad_NoService(t *testing.T) { + c, _ := core.New(core.WithServiceLock()) + _, handled, _ := c.PERFORM(TaskQuestion{}) + assert.False(t, handled) +} + +func TestTaskWarning_Bad_NoService(t *testing.T) { + c, _ := core.New(core.WithServiceLock()) + _, handled, _ := c.PERFORM(TaskWarning{}) + assert.False(t, handled) +} + +func TestTaskError_Bad_NoService(t *testing.T) { + c, _ := core.New(core.WithServiceLock()) + _, handled, _ := c.PERFORM(TaskError{}) + assert.False(t, handled) +} diff --git a/pkg/display/display.go b/pkg/display/display.go index c4b51a90..3ea8329d 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -2,20 +2,19 @@ package display import ( "context" - "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" + coreutil "dappco.re/go/core" "forge.lthn.ai/core/gui/pkg/browser" "forge.lthn.ai/core/gui/pkg/contextmenu" "forge.lthn.ai/core/gui/pkg/dialog" "forge.lthn.ai/core/gui/pkg/dock" "forge.lthn.ai/core/gui/pkg/environment" + "forge.lthn.ai/core/gui/pkg/events" "forge.lthn.ai/core/gui/pkg/keybinding" "forge.lthn.ai/core/gui/pkg/lifecycle" "forge.lthn.ai/core/gui/pkg/menu" @@ -224,6 +223,31 @@ func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { s.events.Emit(Event{Type: EventWebviewException, Window: m.Window, Data: map[string]any{"exception": m.Exception}}) } + case events.ActionEventFired: + if s.events != nil { + s.events.Emit(Event{Type: EventCustomEvent, + Data: map[string]any{"name": m.Name, "data": m.Data}}) + } + case dock.ActionProgressChanged: + if s.events != nil { + s.events.Emit(Event{Type: EventDockProgress, + Data: map[string]any{"value": m.Value}}) + } + case dock.ActionBounceStarted: + if s.events != nil { + s.events.Emit(Event{Type: EventDockBounce, + Data: map[string]any{"bounceId": m.BounceID, "type": m.Type}}) + } + case notification.ActionNotificationActionTriggered: + if s.events != nil { + s.events.Emit(Event{Type: EventNotificationAction, + Data: map[string]any{"notificationId": m.NotificationID, "actionId": m.ActionID}}) + } + case notification.ActionNotificationDismissed: + if s.events != nil { + s.events.Emit(Event{Type: EventNotificationDismiss, + Data: map[string]any{"notificationId": m.NotificationID}}) + } case ActionIDECommand: if s.events != nil { s.events.Emit(Event{Type: EventIDECommand, @@ -287,9 +311,9 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) { result, handled, err = s.Core().QUERY(dock.QueryVisible{}) case "contextmenu:add": name, _ := msg.Data["name"].(string) - menuJSON, _ := json.Marshal(msg.Data["menu"]) + menuJSON := coreutil.JSONMarshalString(msg.Data["menu"]) var menuDef contextmenu.ContextMenuDef - _ = json.Unmarshal(menuJSON, &menuDef) + _ = coreutil.JSONUnmarshalString(menuJSON, &menuDef) result, handled, err = s.Core().PERFORM(contextmenu.TaskAdd{ Name: name, Menu: menuDef, }) @@ -506,11 +530,11 @@ func (s *Service) handleTrayAction(actionID string) { } func guiConfigPath() string { - home, err := os.UserHomeDir() - if err != nil { - return filepath.Join(".core", "gui", "config.yaml") + home := coreutil.Env("DIR_HOME") + if home == "" { + return coreutil.JoinPath(".core", "gui", "config.yaml") } - return filepath.Join(home, ".core", "gui", "config.yaml") + return coreutil.JoinPath(home, ".core", "gui", "config.yaml") } func (s *Service) loadConfig() { diff --git a/pkg/display/display_test.go b/pkg/display/display_test.go index ebc7a8ab..e7b770a1 100644 --- a/pkg/display/display_test.go +++ b/pkg/display/display_test.go @@ -2,11 +2,11 @@ package display import ( "context" - "os" - "path/filepath" "testing" + coreio "forge.lthn.ai/core/go-io" "forge.lthn.ai/core/go/pkg/core" + coreutil "dappco.re/go/core" "forge.lthn.ai/core/gui/pkg/menu" "forge.lthn.ai/core/gui/pkg/systray" "forge.lthn.ai/core/gui/pkg/window" @@ -444,9 +444,9 @@ func TestWSEventManager_Good(t *testing.T) { func TestLoadConfig_Good(t *testing.T) { // Create temp config file dir := t.TempDir() - cfgPath := filepath.Join(dir, ".core", "gui", "config.yaml") - require.NoError(t, os.MkdirAll(filepath.Dir(cfgPath), 0o755)) - require.NoError(t, os.WriteFile(cfgPath, []byte(` + cfgPath := coreutil.JoinPath(dir, ".core", "gui", "config.yaml") + require.NoError(t, coreio.Local.EnsureDir(coreutil.JoinPath(dir, ".core", "gui"))) + require.NoError(t, coreio.Local.Write(cfgPath, ` window: default_width: 1280 default_height: 720 @@ -454,7 +454,7 @@ systray: tooltip: "Test App" menu: show_dev_tools: false -`), 0o644)) +`)) s, _ := NewService() s.loadConfigFrom(cfgPath) @@ -467,7 +467,7 @@ menu: func TestLoadConfig_Bad_MissingFile(t *testing.T) { s, _ := NewService() - s.loadConfigFrom(filepath.Join(t.TempDir(), "nonexistent.yaml")) + s.loadConfigFrom(coreutil.JoinPath(t.TempDir(), "nonexistent.yaml")) // Should not panic, configData stays at empty defaults assert.Empty(t, s.configData["window"]) @@ -477,7 +477,7 @@ func TestLoadConfig_Bad_MissingFile(t *testing.T) { func TestHandleConfigTask_Persists_Good(t *testing.T) { dir := t.TempDir() - cfgPath := filepath.Join(dir, "config.yaml") + cfgPath := coreutil.JoinPath(dir, "config.yaml") s, _ := NewService() s.loadConfigFrom(cfgPath) // Creates empty config (file doesn't exist yet) @@ -499,7 +499,7 @@ func TestHandleConfigTask_Persists_Good(t *testing.T) { assert.True(t, handled) // Verify file was written - data, err := os.ReadFile(cfgPath) + data, err := coreio.Local.Read(cfgPath) require.NoError(t, err) - assert.Contains(t, string(data), "default_width") + assert.Contains(t, data, "default_width") } diff --git a/pkg/display/events.go b/pkg/display/events.go index 6333c3c8..4c72889c 100644 --- a/pkg/display/events.go +++ b/pkg/display/events.go @@ -1,12 +1,12 @@ package display import ( - "encoding/json" "net/http" "strconv" "sync" "time" + core "dappco.re/go/core" "forge.lthn.ai/core/gui/pkg/window" "github.com/gorilla/websocket" ) @@ -40,6 +40,11 @@ const ( EventContextMenuClick EventType = "contextmenu.item-clicked" EventWebviewConsole EventType = "webview.console" EventWebviewException EventType = "webview.exception" + EventCustomEvent EventType = "event.custom" + EventDockProgress EventType = "dock.progress" + EventDockBounce EventType = "dock.bounce" + EventNotificationAction EventType = "notification.action" + EventNotificationDismiss EventType = "notification.dismiss" ) // Event represents a display event sent to subscribers. @@ -129,13 +134,13 @@ func (em *WSEventManager) sendEvent(conn *websocket.Conn, event Event) { return } - data, err := json.Marshal(event) - if err != nil { + result := core.JSONMarshal(event) + if !result.OK { return } conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) - if err := conn.WriteMessage(websocket.TextMessage, data); err != nil { + if err := conn.WriteMessage(websocket.TextMessage, result.Value.([]byte)); err != nil { em.removeClient(conn) } } @@ -173,7 +178,7 @@ func (em *WSEventManager) handleMessages(conn *websocket.Conn) { EventTypes []EventType `json:"eventTypes,omitempty"` } - if err := json.Unmarshal(message, &msg); err != nil { + if !core.JSONUnmarshal(message, &msg).OK { continue } @@ -219,8 +224,9 @@ func (em *WSEventManager) subscribe(conn *websocket.Conn, id string, eventTypes "id": id, "eventTypes": eventTypes, } - data, _ := json.Marshal(response) - conn.WriteMessage(websocket.TextMessage, data) + if r := core.JSONMarshal(response); r.OK { + conn.WriteMessage(websocket.TextMessage, r.Value.([]byte)) + } } // unsubscribe removes a subscription for a client. @@ -242,8 +248,9 @@ func (em *WSEventManager) unsubscribe(conn *websocket.Conn, id string) { "type": "unsubscribed", "id": id, } - data, _ := json.Marshal(response) - conn.WriteMessage(websocket.TextMessage, data) + if r := core.JSONMarshal(response); r.OK { + conn.WriteMessage(websocket.TextMessage, r.Value.([]byte)) + } } // listSubscriptions sends a list of active subscriptions to a client. @@ -267,8 +274,9 @@ func (em *WSEventManager) listSubscriptions(conn *websocket.Conn) { "type": "subscriptions", "subscriptions": subs, } - data, _ := json.Marshal(response) - conn.WriteMessage(websocket.TextMessage, data) + if r := core.JSONMarshal(response); r.OK { + conn.WriteMessage(websocket.TextMessage, r.Value.([]byte)) + } } // removeClient removes a client and its subscriptions. diff --git a/pkg/dock/messages.go b/pkg/dock/messages.go index f45cc31c..86d57aa8 100644 --- a/pkg/dock/messages.go +++ b/pkg/dock/messages.go @@ -23,7 +23,31 @@ type TaskSetBadge struct{ Label string } // TaskRemoveBadge removes the dock/taskbar badge. Result: nil type TaskRemoveBadge struct{} +// TaskSetProgressBar sets the progress indicator on the dock/taskbar icon. +// Value must be in the range [0.0, 1.0]. Use -1.0 to remove the bar. +// Result: nil +// _, _, err := c.PERFORM(TaskSetProgressBar{Value: 0.75}) +type TaskSetProgressBar struct{ Value float64 } + +// TaskBounce animates the dock icon to attract the user's attention. Result: int (bounce ID) +// _, result, err := c.PERFORM(TaskBounce{Type: BounceCritical}) +// bounceID := result.(int) +type TaskBounce struct{ Type BounceType } + +// TaskStopBounce cancels a running dock bounce animation. Result: nil +// _, _, err := c.PERFORM(TaskStopBounce{BounceID: bounceID}) +type TaskStopBounce struct{ BounceID int } + // --- Actions (broadcasts) --- // ActionVisibilityChanged is broadcast after a successful TaskShowIcon or TaskHideIcon. type ActionVisibilityChanged struct{ Visible bool } + +// ActionProgressChanged is broadcast after a successful TaskSetProgressBar. +type ActionProgressChanged struct{ Value float64 } + +// ActionBounceStarted is broadcast after a successful TaskBounce. +type ActionBounceStarted struct { + BounceID int `json:"bounceId"` + Type BounceType `json:"type"` +} diff --git a/pkg/dock/platform.go b/pkg/dock/platform.go index d34004a8..54702cb2 100644 --- a/pkg/dock/platform.go +++ b/pkg/dock/platform.go @@ -1,9 +1,19 @@ // pkg/dock/platform.go package dock +// BounceType controls the style of the macOS dock icon bounce animation. +type BounceType int + +const ( + // BounceInformational bounces the dock icon once briefly. + BounceInformational BounceType = iota + // BounceCritical bounces the dock icon continuously until the app is focused. + BounceCritical +) + // Platform abstracts the dock/taskbar backend (Wails v3). -// macOS: dock icon show/hide + badge. -// Windows: taskbar badge only (show/hide not supported). +// macOS: dock icon show/hide + badge + bounce + progress. +// Windows: taskbar badge and progress only (show/hide/bounce not supported). // Linux: not supported — adapter returns nil for all operations. type Platform interface { ShowIcon() error @@ -11,4 +21,15 @@ type Platform interface { SetBadge(label string) error RemoveBadge() error IsVisible() bool + // SetProgressBar sets a progress indicator on the dock/taskbar icon. + // value is in the range [0.0, 1.0]. Use -1.0 to remove the progress bar. + // p.SetProgressBar(0.5) // 50% progress + SetProgressBar(value float64) error + // Bounce animates the dock icon to attract the user's attention. + // bounceType controls whether the animation loops (BounceCritical) or plays once (BounceInformational). + // Returns a bounce ID that can be passed to StopBounce. + Bounce(bounceType BounceType) (int, error) + // StopBounce cancels a running bounce animation identified by bounceID. + // p.StopBounce(id) + StopBounce(bounceID int) error } diff --git a/pkg/dock/service.go b/pkg/dock/service.go index 346ef95e..735f578b 100644 --- a/pkg/dock/service.go +++ b/pkg/dock/service.go @@ -56,6 +56,24 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { return nil, true, err } return nil, true, nil + case TaskSetProgressBar: + if err := s.platform.SetProgressBar(t.Value); err != nil { + return nil, true, err + } + _ = s.Core().ACTION(ActionProgressChanged{Value: t.Value}) + return nil, true, nil + case TaskBounce: + bounceID, err := s.platform.Bounce(t.Type) + if err != nil { + return nil, true, err + } + _ = s.Core().ACTION(ActionBounceStarted{BounceID: bounceID, Type: t.Type}) + return bounceID, true, nil + case TaskStopBounce: + if err := s.platform.StopBounce(t.BounceID); err != nil { + return nil, true, err + } + return nil, true, nil default: return nil, false, nil } diff --git a/pkg/dock/service_test.go b/pkg/dock/service_test.go index 503e8706..6db7c197 100644 --- a/pkg/dock/service_test.go +++ b/pkg/dock/service_test.go @@ -13,13 +13,21 @@ import ( // --- Mock Platform --- type mockPlatform struct { - visible bool - badge string - hasBadge bool - showErr error - hideErr error - badgeErr error - removeErr error + visible bool + badge string + hasBadge bool + progress float64 + hasProgress bool + bounceID int + bounceCount int + stopBounceIDs []int + showErr error + hideErr error + badgeErr error + removeErr error + progressErr error + bounceErr error + stopBounceErr error } func (m *mockPlatform) ShowIcon() error { @@ -58,6 +66,32 @@ func (m *mockPlatform) RemoveBadge() error { func (m *mockPlatform) IsVisible() bool { return m.visible } +func (m *mockPlatform) SetProgressBar(value float64) error { + if m.progressErr != nil { + return m.progressErr + } + m.progress = value + m.hasProgress = value >= 0 + return nil +} + +func (m *mockPlatform) Bounce(bounceType BounceType) (int, error) { + if m.bounceErr != nil { + return 0, m.bounceErr + } + m.bounceCount++ + m.bounceID++ + return m.bounceID, nil +} + +func (m *mockPlatform) StopBounce(bounceID int) error { + if m.stopBounceErr != nil { + return m.stopBounceErr + } + m.stopBounceIDs = append(m.stopBounceIDs, bounceID) + return nil +} + // --- Test helpers --- func newTestDockService(t *testing.T) (*Service, *core.Core, *mockPlatform) { @@ -192,3 +226,127 @@ func TestTaskSetBadge_Bad(t *testing.T) { assert.True(t, handled) assert.Error(t, err) } + +func TestTaskSetProgressBar_Good(t *testing.T) { + _, c, mock := newTestDockService(t) + + var received *ActionProgressChanged + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if a, ok := msg.(ActionProgressChanged); ok { + received = &a + } + return nil + }) + + _, handled, err := c.PERFORM(TaskSetProgressBar{Value: 0.75}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, 0.75, mock.progress) + assert.True(t, mock.hasProgress) + require.NotNil(t, received) + assert.Equal(t, 0.75, received.Value) +} + +func TestTaskSetProgressBar_Remove_Good(t *testing.T) { + _, c, mock := newTestDockService(t) + + _, _, _ = c.PERFORM(TaskSetProgressBar{Value: 0.5}) + _, handled, err := c.PERFORM(TaskSetProgressBar{Value: -1.0}) + require.NoError(t, err) + assert.True(t, handled) + assert.False(t, mock.hasProgress) +} + +func TestTaskSetProgressBar_Bad(t *testing.T) { + _, c, mock := newTestDockService(t) + mock.progressErr = assert.AnError + + _, handled, err := c.PERFORM(TaskSetProgressBar{Value: 0.5}) + assert.True(t, handled) + assert.Error(t, err) +} + +func TestTaskBounce_Good(t *testing.T) { + _, c, mock := newTestDockService(t) + + var received *ActionBounceStarted + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if a, ok := msg.(ActionBounceStarted); ok { + received = &a + } + return nil + }) + + result, handled, err := c.PERFORM(TaskBounce{Type: BounceInformational}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, 1, mock.bounceCount) + bounceID := result.(int) + assert.Equal(t, 1, bounceID) + require.NotNil(t, received) + assert.Equal(t, 1, received.BounceID) + assert.Equal(t, BounceInformational, received.Type) +} + +func TestTaskBounce_Critical_Good(t *testing.T) { + _, c, mock := newTestDockService(t) + + result, handled, err := c.PERFORM(TaskBounce{Type: BounceCritical}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, 1, mock.bounceCount) + assert.Equal(t, 1, result.(int)) +} + +func TestTaskBounce_Bad(t *testing.T) { + _, c, mock := newTestDockService(t) + mock.bounceErr = assert.AnError + + _, handled, err := c.PERFORM(TaskBounce{Type: BounceInformational}) + assert.True(t, handled) + assert.Error(t, err) +} + +func TestTaskStopBounce_Good(t *testing.T) { + _, c, mock := newTestDockService(t) + + result, _, _ := c.PERFORM(TaskBounce{Type: BounceInformational}) + bounceID := result.(int) + + _, handled, err := c.PERFORM(TaskStopBounce{BounceID: bounceID}) + require.NoError(t, err) + assert.True(t, handled) + assert.Contains(t, mock.stopBounceIDs, bounceID) +} + +func TestTaskStopBounce_Bad(t *testing.T) { + _, c, mock := newTestDockService(t) + mock.stopBounceErr = assert.AnError + + _, handled, err := c.PERFORM(TaskStopBounce{BounceID: 99}) + assert.True(t, handled) + assert.Error(t, err) +} + +func TestBounceType_Ugly(t *testing.T) { + // BounceInformational and BounceCritical must be distinct constants. + assert.NotEqual(t, BounceInformational, BounceCritical) +} + +func TestTaskSetProgressBar_Bad_NoService(t *testing.T) { + c, _ := core.New(core.WithServiceLock()) + _, handled, _ := c.PERFORM(TaskSetProgressBar{}) + assert.False(t, handled) +} + +func TestTaskBounce_Bad_NoService(t *testing.T) { + c, _ := core.New(core.WithServiceLock()) + _, handled, _ := c.PERFORM(TaskBounce{}) + assert.False(t, handled) +} + +func TestTaskStopBounce_Bad_NoService(t *testing.T) { + c, _ := core.New(core.WithServiceLock()) + _, handled, _ := c.PERFORM(TaskStopBounce{}) + assert.False(t, handled) +} diff --git a/pkg/environment/messages.go b/pkg/environment/messages.go index 8813dc19..2beaeddc 100644 --- a/pkg/environment/messages.go +++ b/pkg/environment/messages.go @@ -16,6 +16,11 @@ type TaskOpenFileManager struct { Select bool `json:"select"` } +// QueryFocusFollowsMouse returns whether focus-follows-mouse is enabled. Result: bool +// +// result := c.QUERY(environment.QueryFocusFollowsMouse{}) +type QueryFocusFollowsMouse struct{} + // ActionThemeChanged is broadcast when the system theme changes. type ActionThemeChanged struct { IsDark bool `json:"isDark"` diff --git a/pkg/environment/platform.go b/pkg/environment/platform.go index 3e403f9b..b513dabf 100644 --- a/pkg/environment/platform.go +++ b/pkg/environment/platform.go @@ -7,6 +7,7 @@ type Platform interface { Info() EnvironmentInfo AccentColour() string OpenFileManager(path string, selectFile bool) error + HasFocusFollowsMouse() bool OnThemeChange(handler func(isDark bool)) func() // returns cancel func } diff --git a/pkg/environment/service.go b/pkg/environment/service.go index 8f4d18a5..6cc41923 100644 --- a/pkg/environment/service.go +++ b/pkg/environment/service.go @@ -61,6 +61,8 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) { return s.platform.Info(), true, nil case QueryAccentColour: return s.platform.AccentColour(), true, nil + case QueryFocusFollowsMouse: + return s.platform.HasFocusFollowsMouse(), true, nil default: return nil, false, nil } diff --git a/pkg/environment/service_test.go b/pkg/environment/service_test.go index 76ec531e..d5dae911 100644 --- a/pkg/environment/service_test.go +++ b/pkg/environment/service_test.go @@ -26,6 +26,7 @@ func (m *mockPlatform) AccentColour() string { return m.accentColour } func (m *mockPlatform) OpenFileManager(path string, selectFile bool) error { return m.openFMErr } +func (m *mockPlatform) HasFocusFollowsMouse() bool { return false } func (m *mockPlatform) OnThemeChange(handler func(isDark bool)) func() { m.mu.Lock() m.themeHandler = handler diff --git a/pkg/events/messages.go b/pkg/events/messages.go new file mode 100644 index 00000000..b85b5f88 --- /dev/null +++ b/pkg/events/messages.go @@ -0,0 +1,34 @@ +// pkg/events/messages.go +package events + +// TaskEmit fires a custom event by name with optional data. +// c.PERFORM(events.TaskEmit{Name: "build:done", Data: result}) +type TaskEmit struct { + Name string + Data any +} + +// TaskOn registers a persistent listener for a named event. +// The listener ID returned in the result can be used with TaskOff. +// c.PERFORM(events.TaskOn{Name: "build:done"}) +type TaskOn struct { + Name string +} + +// TaskOff removes all listeners for a named event. +// c.PERFORM(events.TaskOff{Name: "build:done"}) +type TaskOff struct { + Name string +} + +// QueryListeners returns the count of listeners registered for a named event. +// count := c.QUERY(events.QueryListeners{Name: "build:done"}) +type QueryListeners struct { + Name string +} + +// ActionEventFired is broadcast when a custom event is emitted via TaskEmit. +type ActionEventFired struct { + Name string + Data any +} diff --git a/pkg/events/platform.go b/pkg/events/platform.go new file mode 100644 index 00000000..b75a0500 --- /dev/null +++ b/pkg/events/platform.go @@ -0,0 +1,26 @@ +// pkg/events/platform.go +package events + +// Platform abstracts the custom event backend (Wails v3 EventManager). +// Emit fires an event by name with optional data arguments. +// On registers a persistent listener; returns a cancel function. +// Off removes all listeners for the named event. +// OnMultiple registers a listener that auto-deregisters after counter firings. +// Reset removes all custom event listeners. +// +// cancel := platform.On("build:done", func(e *CustomEvent) { ... }) +// defer cancel() +// platform.Emit("build:done", result) +type Platform interface { + Emit(name string, data ...any) bool + On(name string, callback func(event *CustomEvent)) func() + Off(name string) + OnMultiple(name string, callback func(event *CustomEvent), counter int) + Reset() +} + +// CustomEvent is the event object delivered to On/OnMultiple listeners. +type CustomEvent struct { + Name string + Data any +} diff --git a/pkg/events/register.go b/pkg/events/register.go new file mode 100644 index 00000000..5eb65550 --- /dev/null +++ b/pkg/events/register.go @@ -0,0 +1,16 @@ +// pkg/events/register.go +package events + +import "forge.lthn.ai/core/go/pkg/core" + +// Register(p) binds the events service to a Core instance. +// core.WithService(events.Register(wailsEventManager)) +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, + counts: make(map[string]int), + }, nil + } +} diff --git a/pkg/events/service.go b/pkg/events/service.go new file mode 100644 index 00000000..a5516e2b --- /dev/null +++ b/pkg/events/service.go @@ -0,0 +1,103 @@ +// pkg/events/service.go +package events + +import ( + "context" + "sync" + + coreerr "forge.lthn.ai/core/go-log" + "forge.lthn.ai/core/go/pkg/core" +) + +// Options holds configuration for the events service (currently none). +type Options struct{} + +// Service bridges the platform custom event system to the Core IPC bus. +type Service struct { + *core.ServiceRuntime[Options] + platform Platform + mu sync.RWMutex + counts map[string]int // listener count per event name + cancels []func() +} + +func (s *Service) OnStartup(ctx context.Context) error { + s.Core().RegisterQuery(s.handleQuery) + s.Core().RegisterTask(s.handleTask) + return nil +} + +func (s *Service) OnShutdown(ctx context.Context) error { + s.mu.Lock() + defer s.mu.Unlock() + for _, cancel := range s.cancels { + cancel() + } + s.cancels = nil + s.counts = make(map[string]int) + s.platform.Reset() + return nil +} + +func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { + return nil +} + +func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) { + switch q := q.(type) { + case QueryListeners: + s.mu.RLock() + count := s.counts[q.Name] + s.mu.RUnlock() + return count, true, nil + default: + return nil, false, nil + } +} + +func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { + switch t := t.(type) { + case TaskEmit: + return s.taskEmit(t) + case TaskOn: + return s.taskOn(t) + case TaskOff: + return nil, true, s.taskOff(t) + default: + return nil, false, nil + } +} + +func (s *Service) taskEmit(t TaskEmit) (any, bool, error) { + if t.Name == "" { + return nil, true, coreerr.E("events.taskEmit", "event name is required", nil) + } + cancelled := s.platform.Emit(t.Name, t.Data) + _ = s.Core().ACTION(ActionEventFired{Name: t.Name, Data: t.Data}) + return cancelled, true, nil +} + +func (s *Service) taskOn(t TaskOn) (any, bool, error) { + if t.Name == "" { + return nil, true, coreerr.E("events.taskOn", "event name is required", nil) + } + cancel := s.platform.On(t.Name, func(event *CustomEvent) { + _ = s.Core().ACTION(ActionEventFired{Name: event.Name, Data: event.Data}) + }) + s.mu.Lock() + s.cancels = append(s.cancels, cancel) + s.counts[t.Name]++ + s.mu.Unlock() + return nil, true, nil +} + +func (s *Service) taskOff(t TaskOff) error { + if t.Name == "" { + return coreerr.E("events.taskOff", "event name is required", nil) + } + s.platform.Off(t.Name) + s.mu.Lock() + delete(s.counts, t.Name) + s.mu.Unlock() + return nil +} diff --git a/pkg/events/service_test.go b/pkg/events/service_test.go new file mode 100644 index 00000000..2ea9acb3 --- /dev/null +++ b/pkg/events/service_test.go @@ -0,0 +1,272 @@ +// pkg/events/service_test.go +package events + +import ( + "context" + "sync" + "testing" + + "forge.lthn.ai/core/go/pkg/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- Mock Platform --- + +type mockPlatform struct { + mu sync.Mutex + listeners map[string][]*mockListener + emitted []mockEmit +} + +type mockListener struct { + callback func(*CustomEvent) +} + +type mockEmit struct { + name string + data any +} + +func newMockPlatform() *mockPlatform { + return &mockPlatform{ + listeners: make(map[string][]*mockListener), + } +} + +func (m *mockPlatform) Emit(name string, data ...any) bool { + m.mu.Lock() + var payload any + if len(data) == 1 { + payload = data[0] + } else if len(data) > 1 { + payload = data + } + m.emitted = append(m.emitted, mockEmit{name: name, data: payload}) + listeners := append([]*mockListener(nil), m.listeners[name]...) + m.mu.Unlock() + + event := &CustomEvent{Name: name, Data: payload} + for _, l := range listeners { + l.callback(event) + } + return false +} + +func (m *mockPlatform) On(name string, callback func(*CustomEvent)) func() { + m.mu.Lock() + defer m.mu.Unlock() + listener := &mockListener{callback: callback} + m.listeners[name] = append(m.listeners[name], listener) + return func() { + m.mu.Lock() + defer m.mu.Unlock() + slice := m.listeners[name] + for i, l := range slice { + if l == listener { + m.listeners[name] = append(slice[:i], slice[i+1:]...) + return + } + } + } +} + +func (m *mockPlatform) Off(name string) { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.listeners, name) +} + +func (m *mockPlatform) OnMultiple(name string, callback func(*CustomEvent), counter int) { + m.mu.Lock() + defer m.mu.Unlock() + listener := &mockListener{callback: callback} + m.listeners[name] = append(m.listeners[name], listener) +} + +func (m *mockPlatform) Reset() { + m.mu.Lock() + defer m.mu.Unlock() + m.listeners = make(map[string][]*mockListener) +} + +func (m *mockPlatform) listenerCount(name string) int { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.listeners[name]) +} + +func (m *mockPlatform) emitCount() int { + m.mu.Lock() + defer m.mu.Unlock() + return len(m.emitted) +} + +// --- Test helpers --- + +func newTestEventsService(t *testing.T) (*Service, *core.Core, *mockPlatform) { + t.Helper() + mock := newMockPlatform() + c, err := core.New( + core.WithService(Register(mock)), + core.WithServiceLock(), + ) + require.NoError(t, err) + require.NoError(t, c.ServiceStartup(context.Background(), nil)) + svc := core.MustServiceFor[*Service](c, "events") + return svc, c, mock +} + +// --- Good tests --- + +func TestRegister_Good(t *testing.T) { + svc, _, _ := newTestEventsService(t) + assert.NotNil(t, svc) +} + +func TestTaskEmit_Good(t *testing.T) { + _, c, mock := newTestEventsService(t) + + var fired ActionEventFired + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if a, ok := msg.(ActionEventFired); ok { + fired = a + } + return nil + }) + + _, handled, err := c.PERFORM(TaskEmit{Name: "ping", Data: "hello"}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, 1, mock.emitCount()) + assert.Equal(t, "ping", fired.Name) + assert.Equal(t, "hello", fired.Data) +} + +func TestTaskOn_Good(t *testing.T) { + _, c, mock := newTestEventsService(t) + + _, handled, err := c.PERFORM(TaskOn{Name: "build:done"}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, 1, mock.listenerCount("build:done")) +} + +func TestTaskOff_Good(t *testing.T) { + _, c, mock := newTestEventsService(t) + + _, _, _ = c.PERFORM(TaskOn{Name: "build:done"}) + assert.Equal(t, 1, mock.listenerCount("build:done")) + + _, handled, err := c.PERFORM(TaskOff{Name: "build:done"}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, 0, mock.listenerCount("build:done")) +} + +func TestQueryListeners_Good(t *testing.T) { + _, c, _ := newTestEventsService(t) + + _, _, _ = c.PERFORM(TaskOn{Name: "my:event"}) + _, _, _ = c.PERFORM(TaskOn{Name: "my:event"}) + + result, handled, err := c.QUERY(QueryListeners{Name: "my:event"}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, 2, result.(int)) +} + +func TestOnShutdown_ResetsAll_Good(t *testing.T) { + svc, _, mock := newTestEventsService(t) + + _, _, _ = svc.Core().PERFORM(TaskOn{Name: "a"}) + _, _, _ = svc.Core().PERFORM(TaskOn{Name: "b"}) + + err := svc.OnShutdown(context.Background()) + require.NoError(t, err) + + assert.Equal(t, 0, mock.listenerCount("a")) + assert.Equal(t, 0, mock.listenerCount("b")) + + result, _, _ := svc.Core().QUERY(QueryListeners{Name: "a"}) + assert.Equal(t, 0, result.(int)) +} + +// --- Bad tests --- + +func TestTaskEmit_Bad_EmptyName(t *testing.T) { + _, c, _ := newTestEventsService(t) + _, handled, err := c.PERFORM(TaskEmit{Name: ""}) + assert.True(t, handled) + assert.Error(t, err) +} + +func TestTaskOn_Bad_EmptyName(t *testing.T) { + _, c, _ := newTestEventsService(t) + _, handled, err := c.PERFORM(TaskOn{Name: ""}) + assert.True(t, handled) + assert.Error(t, err) +} + +func TestTaskOff_Bad_EmptyName(t *testing.T) { + _, c, _ := newTestEventsService(t) + _, handled, err := c.PERFORM(TaskOff{Name: ""}) + assert.True(t, handled) + assert.Error(t, err) +} + +func TestQueryListeners_Bad_NoService(t *testing.T) { + // No events service registered — query is not handled + c, err := core.New(core.WithServiceLock()) + require.NoError(t, err) + _, handled, _ := c.QUERY(QueryListeners{Name: "anything"}) + assert.False(t, handled) +} + +// --- Ugly tests --- + +func TestTaskEmit_Ugly_NilData(t *testing.T) { + _, c, mock := newTestEventsService(t) + _, handled, err := c.PERFORM(TaskEmit{Name: "null:event", Data: nil}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, 1, mock.emitCount()) +} + +func TestTaskOn_Ugly_MultipleListenersSameEvent(t *testing.T) { + _, c, mock := newTestEventsService(t) + + for i := 0; i < 5; i++ { + _, _, _ = c.PERFORM(TaskOn{Name: "flood"}) + } + + result, _, _ := c.QUERY(QueryListeners{Name: "flood"}) + assert.Equal(t, 5, result.(int)) + assert.Equal(t, 5, mock.listenerCount("flood")) +} + +func TestTaskOff_Ugly_NonExistentEvent(t *testing.T) { + // Off on unknown event name should not error + _, c, _ := newTestEventsService(t) + _, handled, err := c.PERFORM(TaskOff{Name: "never-registered"}) + require.NoError(t, err) + assert.True(t, handled) +} + +func TestTaskEmit_Ugly_ActionBroadcastOnEachEmit(t *testing.T) { + _, c, _ := newTestEventsService(t) + + var count int + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if _, ok := msg.(ActionEventFired); ok { + count++ + } + return nil + }) + + for i := 0; i < 3; i++ { + _, _, _ = c.PERFORM(TaskEmit{Name: "tick"}) + } + + assert.Equal(t, 3, count) +} diff --git a/pkg/keybinding/messages.go b/pkg/keybinding/messages.go index 08771d2a..a9faa240 100644 --- a/pkg/keybinding/messages.go +++ b/pkg/keybinding/messages.go @@ -1,8 +1,12 @@ package keybinding -import "errors" +import coreerr "forge.lthn.ai/core/go-log" -var ErrorAlreadyRegistered = errors.New("keybinding: accelerator already registered") +// ErrorAlreadyRegistered is returned by TaskAdd when the accelerator is already bound. +var ErrorAlreadyRegistered = coreerr.NewError("keybinding: accelerator already registered") + +// ErrorNotRegistered is returned by TaskRemove and TaskProcess when the accelerator is unknown. +var ErrorNotRegistered = coreerr.NewError("keybinding: accelerator not registered") // BindingInfo describes a registered global key binding. type BindingInfo struct { @@ -24,6 +28,13 @@ type TaskRemove struct { Accelerator string `json:"accelerator"` } +// TaskProcess programmatically triggers a registered key binding as if the user pressed it. +// Error: ErrorNotRegistered if the accelerator has not been registered. +// _, _, err := c.PERFORM(TaskProcess{Accelerator: "Ctrl+S"}) +type TaskProcess struct { + Accelerator string `json:"accelerator"` +} + // ActionTriggered is broadcast when a registered key binding fires. type ActionTriggered struct { Accelerator string `json:"accelerator"` diff --git a/pkg/keybinding/platform.go b/pkg/keybinding/platform.go index 732ad202..9ce6e6d2 100644 --- a/pkg/keybinding/platform.go +++ b/pkg/keybinding/platform.go @@ -12,6 +12,11 @@ type Platform interface { // Remove unregisters a previously registered keyboard shortcut. Remove(accelerator string) error + // Process programmatically triggers the shortcut as if the user pressed it. + // Returns an error if the platform cannot trigger the shortcut. + // p.Process("Ctrl+S") + Process(accelerator string) error + // GetAll returns all currently registered accelerator strings. // Used for adapter-level reconciliation only — not read by QueryList. GetAll() []string diff --git a/pkg/keybinding/service.go b/pkg/keybinding/service.go index 3afd23b7..fb8d2578 100644 --- a/pkg/keybinding/service.go +++ b/pkg/keybinding/service.go @@ -53,6 +53,8 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { return nil, true, s.taskAdd(t) case TaskRemove: return nil, true, s.taskRemove(t) + case TaskProcess: + return nil, true, s.taskProcess(t) default: return nil, false, nil } @@ -80,7 +82,7 @@ func (s *Service) taskAdd(t TaskAdd) error { func (s *Service) taskRemove(t TaskRemove) error { if _, exists := s.registeredBindings[t.Accelerator]; !exists { - return coreerr.E("keybinding.taskRemove", "not registered: "+t.Accelerator, nil) + return ErrorNotRegistered } err := s.platform.Remove(t.Accelerator) @@ -91,3 +93,16 @@ func (s *Service) taskRemove(t TaskRemove) error { delete(s.registeredBindings, t.Accelerator) return nil } + +func (s *Service) taskProcess(t TaskProcess) error { + if _, exists := s.registeredBindings[t.Accelerator]; !exists { + return ErrorNotRegistered + } + + err := s.platform.Process(t.Accelerator) + if err != nil { + return coreerr.E("keybinding.taskProcess", "platform process failed", err) + } + + return nil +} diff --git a/pkg/keybinding/service_test.go b/pkg/keybinding/service_test.go index b586e076..942a95af 100644 --- a/pkg/keybinding/service_test.go +++ b/pkg/keybinding/service_test.go @@ -11,11 +11,13 @@ import ( "github.com/stretchr/testify/require" ) -// mockPlatform records Add/Remove calls and allows triggering shortcuts. +// mockPlatform records Add/Remove/Process calls and allows triggering shortcuts. type mockPlatform struct { - mu sync.Mutex - handlers map[string]func() - removed []string + mu sync.Mutex + handlers map[string]func() + removed []string + processed []string + processErr error } func newMockPlatform() *mockPlatform { @@ -37,6 +39,16 @@ func (m *mockPlatform) Remove(accelerator string) error { return nil } +func (m *mockPlatform) Process(accelerator string) error { + m.mu.Lock() + defer m.mu.Unlock() + if m.processErr != nil { + return m.processErr + } + m.processed = append(m.processed, accelerator) + return nil +} + func (m *mockPlatform) GetAll() []string { m.mu.Lock() defer m.mu.Unlock() @@ -121,7 +133,7 @@ func TestTaskRemove_Bad_NotFound(t *testing.T) { _, handled, err := c.PERFORM(TaskRemove{Accelerator: "Ctrl+X"}) assert.True(t, handled) - assert.Error(t, err) + assert.ErrorIs(t, err, ErrorNotRegistered) } func TestQueryList_Good(t *testing.T) { @@ -199,3 +211,47 @@ func TestQueryList_Bad_NoService(t *testing.T) { _, handled, _ := c.QUERY(QueryList{}) assert.False(t, handled) } + +func TestTaskProcess_Good(t *testing.T) { + mp := newMockPlatform() + _, c := newTestKeybindingService(t, mp) + + _, _, _ = c.PERFORM(TaskAdd{Accelerator: "Ctrl+P", Description: "Print"}) + + _, handled, err := c.PERFORM(TaskProcess{Accelerator: "Ctrl+P"}) + require.NoError(t, err) + assert.True(t, handled) + assert.Contains(t, mp.processed, "Ctrl+P") +} + +func TestTaskProcess_Bad_NotRegistered(t *testing.T) { + mp := newMockPlatform() + _, c := newTestKeybindingService(t, mp) + + _, handled, err := c.PERFORM(TaskProcess{Accelerator: "Ctrl+Z"}) + assert.True(t, handled) + assert.ErrorIs(t, err, ErrorNotRegistered) +} + +func TestTaskProcess_Bad_NoService(t *testing.T) { + c, _ := core.New(core.WithServiceLock()) + _, handled, _ := c.PERFORM(TaskProcess{}) + assert.False(t, handled) +} + +func TestTaskProcess_Bad_PlatformError(t *testing.T) { + mp := newMockPlatform() + mp.processErr = assert.AnError + _, c := newTestKeybindingService(t, mp) + + _, _, _ = c.PERFORM(TaskAdd{Accelerator: "Ctrl+P", Description: "Print"}) + _, handled, err := c.PERFORM(TaskProcess{Accelerator: "Ctrl+P"}) + assert.True(t, handled) + assert.Error(t, err) +} + +func TestErrorNotRegistered_Ugly(t *testing.T) { + // ErrorNotRegistered and ErrorAlreadyRegistered must be distinct sentinels. + assert.NotEqual(t, ErrorNotRegistered, ErrorAlreadyRegistered) + assert.NotErrorIs(t, ErrorNotRegistered, ErrorAlreadyRegistered) +} diff --git a/pkg/mcp/subsystem.go b/pkg/mcp/subsystem.go index 2d3be0c4..1f51f84e 100644 --- a/pkg/mcp/subsystem.go +++ b/pkg/mcp/subsystem.go @@ -39,4 +39,5 @@ func (s *Subsystem) RegisterTools(server *mcp.Server) { s.registerKeybindingTools(server) s.registerDockTools(server) s.registerLifecycleTools(server) + s.registerEventTools(server) } diff --git a/pkg/mcp/tools_events.go b/pkg/mcp/tools_events.go new file mode 100644 index 00000000..1238d31a --- /dev/null +++ b/pkg/mcp/tools_events.go @@ -0,0 +1,100 @@ +// pkg/mcp/tools_events.go +package mcp + +import ( + "context" + + coreerr "forge.lthn.ai/core/go-log" + "forge.lthn.ai/core/gui/pkg/events" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// --- event_emit --- + +type EventEmitInput struct { + Name string `json:"name"` + Data any `json:"data,omitempty"` +} +type EventEmitOutput struct { + Success bool `json:"success"` +} + +// eventEmit fires a custom event by name with optional data. +// c.PERFORM(events.TaskEmit{Name: "build:done", Data: result}) +func (s *Subsystem) eventEmit(_ context.Context, _ *mcp.CallToolRequest, input EventEmitInput) (*mcp.CallToolResult, EventEmitOutput, error) { + _, _, err := s.core.PERFORM(events.TaskEmit{Name: input.Name, Data: input.Data}) + if err != nil { + return nil, EventEmitOutput{}, err + } + return nil, EventEmitOutput{Success: true}, nil +} + +// --- event_on --- + +type EventOnInput struct { + Name string `json:"name"` +} +type EventOnOutput struct { + Success bool `json:"success"` +} + +// eventOn registers a persistent listener for a named event. +// c.PERFORM(events.TaskOn{Name: "build:done"}) +func (s *Subsystem) eventOn(_ context.Context, _ *mcp.CallToolRequest, input EventOnInput) (*mcp.CallToolResult, EventOnOutput, error) { + _, _, err := s.core.PERFORM(events.TaskOn{Name: input.Name}) + if err != nil { + return nil, EventOnOutput{}, err + } + return nil, EventOnOutput{Success: true}, nil +} + +// --- event_off --- + +type EventOffInput struct { + Name string `json:"name"` +} +type EventOffOutput struct { + Success bool `json:"success"` +} + +// eventOff removes all listeners for a named event. +// c.PERFORM(events.TaskOff{Name: "build:done"}) +func (s *Subsystem) eventOff(_ context.Context, _ *mcp.CallToolRequest, input EventOffInput) (*mcp.CallToolResult, EventOffOutput, error) { + _, _, err := s.core.PERFORM(events.TaskOff{Name: input.Name}) + if err != nil { + return nil, EventOffOutput{}, err + } + return nil, EventOffOutput{Success: true}, nil +} + +// --- event_list --- + +type EventListInput struct { + Name string `json:"name"` +} +type EventListOutput struct { + Count int `json:"count"` +} + +// eventList returns the number of listeners registered for a named event. +// count := c.QUERY(events.QueryListeners{Name: "build:done"}) +func (s *Subsystem) eventList(_ context.Context, _ *mcp.CallToolRequest, input EventListInput) (*mcp.CallToolResult, EventListOutput, error) { + result, _, err := s.core.QUERY(events.QueryListeners{Name: input.Name}) + if err != nil { + return nil, EventListOutput{}, err + } + count, ok := result.(int) + if !ok { + return nil, EventListOutput{}, coreerr.E("mcp.eventList", "unexpected result type", nil) + } + return nil, EventListOutput{Count: count}, nil +} + +// --- Registration --- + +func (s *Subsystem) registerEventTools(server *mcp.Server) { + mcp.AddTool(server, &mcp.Tool{Name: "event_emit", Description: "Fire a custom event by name with optional data"}, s.eventEmit) + mcp.AddTool(server, &mcp.Tool{Name: "event_on", Description: "Register a persistent listener for a named event"}, s.eventOn) + mcp.AddTool(server, &mcp.Tool{Name: "event_off", Description: "Remove all listeners for a named event"}, s.eventOff) + mcp.AddTool(server, &mcp.Tool{Name: "event_list", Description: "Return the number of listeners registered for a named event"}, s.eventList) +} diff --git a/pkg/notification/messages.go b/pkg/notification/messages.go index 67da238e..784dfd5c 100644 --- a/pkg/notification/messages.go +++ b/pkg/notification/messages.go @@ -9,5 +9,26 @@ type TaskSend struct{ Options NotificationOptions } // TaskRequestPermission requests notification permission from the OS. Result: bool (granted) type TaskRequestPermission struct{} +// TaskRevokePermission revokes previously granted notification permission. +// Result: nil +// _, _, err := c.PERFORM(TaskRevokePermission{}) +type TaskRevokePermission struct{} + +// TaskRegisterCategory registers a notification category with its action buttons. +// Must be called before sending notifications that use that category. +// _, _, err := c.PERFORM(TaskRegisterCategory{Category: NotificationCategory{ID: "message", Actions: [...]}}) +type TaskRegisterCategory struct{ Category NotificationCategory } + // ActionNotificationClicked is broadcast when the user clicks a notification. type ActionNotificationClicked struct{ ID string } + +// ActionNotificationActionTriggered is broadcast when the user taps an action button on a notification. +type ActionNotificationActionTriggered struct { + NotificationID string `json:"notificationId"` + ActionID string `json:"actionId"` +} + +// ActionNotificationDismissed is broadcast when the user dismisses a notification without acting on it. +type ActionNotificationDismissed struct { + NotificationID string `json:"notificationId"` +} diff --git a/pkg/notification/platform.go b/pkg/notification/platform.go index 954a5af9..57639ce8 100644 --- a/pkg/notification/platform.go +++ b/pkg/notification/platform.go @@ -6,6 +6,8 @@ type Platform interface { Send(options NotificationOptions) error RequestPermission() (bool, error) CheckPermission() (bool, error) + RevokePermission() error + RegisterCategory(category NotificationCategory) error } // NotificationSeverity indicates the severity for dialog fallback. @@ -30,3 +32,16 @@ type NotificationOptions struct { type PermissionStatus struct { Granted bool `json:"granted"` } + +// NotificationAction describes a tappable action button on a notification. +type NotificationAction struct { + ID string `json:"id"` + Title string `json:"title"` +} + +// NotificationCategory groups a set of actions that can appear on notifications. +// Register categories on startup so the OS knows the available action buttons. +type NotificationCategory struct { + ID string `json:"id"` + Actions []NotificationAction `json:"actions"` +} diff --git a/pkg/notification/service.go b/pkg/notification/service.go index 7dc412bc..866a87f7 100644 --- a/pkg/notification/service.go +++ b/pkg/notification/service.go @@ -53,6 +53,10 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { case TaskRequestPermission: granted, err := s.platform.RequestPermission() return granted, true, err + case TaskRevokePermission: + return nil, true, s.platform.RevokePermission() + case TaskRegisterCategory: + return nil, true, s.platform.RegisterCategory(t.Category) default: return nil, false, nil } diff --git a/pkg/notification/service_test.go b/pkg/notification/service_test.go index 8689ddf2..d6ddb515 100644 --- a/pkg/notification/service_test.go +++ b/pkg/notification/service_test.go @@ -13,11 +13,16 @@ import ( ) type mockPlatform struct { - sendErr error - permGranted bool - permErr error - lastOpts NotificationOptions - sendCalled bool + sendErr error + permGranted bool + permErr error + revokeErr error + registerCategoryErr error + lastOpts NotificationOptions + lastCategory NotificationCategory + sendCalled bool + revokeCalled bool + registerCategoryCalled bool } func (m *mockPlatform) Send(opts NotificationOptions) error { @@ -27,6 +32,15 @@ func (m *mockPlatform) Send(opts NotificationOptions) error { } func (m *mockPlatform) RequestPermission() (bool, error) { return m.permGranted, m.permErr } func (m *mockPlatform) CheckPermission() (bool, error) { return m.permGranted, m.permErr } +func (m *mockPlatform) RevokePermission() error { + m.revokeCalled = true + return m.revokeErr +} +func (m *mockPlatform) RegisterCategory(category NotificationCategory) error { + m.registerCategoryCalled = true + m.lastCategory = category + return m.registerCategoryErr +} // mockDialogPlatform tracks whether MessageDialog was called (for fallback test). type mockDialogPlatform struct { @@ -117,3 +131,88 @@ func TestTaskSend_Bad(t *testing.T) { _, handled, _ := c.PERFORM(TaskSend{}) assert.False(t, handled) } + +func TestTaskRevokePermission_Good(t *testing.T) { + mock, c := newTestService(t) + _, handled, err := c.PERFORM(TaskRevokePermission{}) + require.NoError(t, err) + assert.True(t, handled) + assert.True(t, mock.revokeCalled) +} + +func TestTaskRevokePermission_Bad_NoService(t *testing.T) { + c, _ := core.New(core.WithServiceLock()) + _, handled, _ := c.PERFORM(TaskRevokePermission{}) + assert.False(t, handled) +} + +func TestTaskRegisterCategory_Good(t *testing.T) { + mock, c := newTestService(t) + category := NotificationCategory{ + ID: "message", + Actions: []NotificationAction{ + {ID: "reply", Title: "Reply"}, + {ID: "dismiss", Title: "Dismiss"}, + }, + } + + _, handled, err := c.PERFORM(TaskRegisterCategory{Category: category}) + require.NoError(t, err) + assert.True(t, handled) + assert.True(t, mock.registerCategoryCalled) + assert.Equal(t, "message", mock.lastCategory.ID) + assert.Len(t, mock.lastCategory.Actions, 2) + assert.Equal(t, "reply", mock.lastCategory.Actions[0].ID) +} + +func TestTaskRegisterCategory_Bad_NoService(t *testing.T) { + c, _ := core.New(core.WithServiceLock()) + _, handled, _ := c.PERFORM(TaskRegisterCategory{}) + assert.False(t, handled) +} + +func TestActionNotificationActionTriggered_Ugly(t *testing.T) { + // Verify the action structs are distinct types. + var triggered ActionNotificationActionTriggered + var dismissed ActionNotificationDismissed + triggered.NotificationID = "n1" + triggered.ActionID = "reply" + dismissed.NotificationID = "n1" + assert.Equal(t, "n1", triggered.NotificationID) + assert.Equal(t, "reply", triggered.ActionID) + assert.Equal(t, "n1", dismissed.NotificationID) +} + +func TestActionNotificationDismissed_Good(t *testing.T) { + _, c := newTestService(t) + + var received *ActionNotificationDismissed + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if a, ok := msg.(ActionNotificationDismissed); ok { + received = &a + } + return nil + }) + + // Broadcast dismissed action directly (as the platform adapter would). + _ = c.ACTION(ActionNotificationDismissed{NotificationID: "notif-42"}) + require.NotNil(t, received) + assert.Equal(t, "notif-42", received.NotificationID) +} + +func TestActionNotificationActionTriggered_Good(t *testing.T) { + _, c := newTestService(t) + + var received *ActionNotificationActionTriggered + c.RegisterAction(func(_ *core.Core, msg core.Message) error { + if a, ok := msg.(ActionNotificationActionTriggered); ok { + received = &a + } + return nil + }) + + _ = c.ACTION(ActionNotificationActionTriggered{NotificationID: "notif-7", ActionID: "archive"}) + require.NotNil(t, received) + assert.Equal(t, "notif-7", received.NotificationID) + assert.Equal(t, "archive", received.ActionID) +} diff --git a/pkg/screen/messages.go b/pkg/screen/messages.go index 07753845..12029a1c 100644 --- a/pkg/screen/messages.go +++ b/pkg/screen/messages.go @@ -7,6 +7,10 @@ type QueryAll struct{} // QueryPrimary returns the primary screen. Result: *Screen (nil if not found) type QueryPrimary struct{} +// QueryCurrent returns the screen currently in use (e.g. containing the focused window). +// Result: *Screen (nil if not determinable) +type QueryCurrent struct{} + // QueryByID returns a screen by ID. Result: *Screen (nil if not found) type QueryByID struct{ ID string } diff --git a/pkg/screen/platform.go b/pkg/screen/platform.go index 97d950d4..9e013b2a 100644 --- a/pkg/screen/platform.go +++ b/pkg/screen/platform.go @@ -5,6 +5,7 @@ package screen type Platform interface { GetAll() []Screen GetPrimary() *Screen + GetCurrent() *Screen } // Screen describes a display/monitor. @@ -27,8 +28,60 @@ type Rect struct { Height int `json:"height"` } +// Contains reports whether the point (x, y) lies within the rectangle. +// +// if rect.Contains(mouseX, mouseY) { handleClick() } +func (r Rect) Contains(x, y int) bool { + return x >= r.X && x < r.X+r.Width && y >= r.Y && y < r.Y+r.Height +} + +// Overlaps reports whether the rectangle r overlaps with other. +// +// if bounds.Overlaps(workArea) { show() } +func (r Rect) Overlaps(other Rect) bool { + return r.X < other.X+other.Width && + r.X+r.Width > other.X && + r.Y < other.Y+other.Height && + r.Y+r.Height > other.Y +} + +// Center returns the centre point of the rectangle. +// +// cx, cy := rect.Center() +func (r Rect) Center() (x, y int) { + return r.X + r.Width/2, r.Y + r.Height/2 +} + // Size represents dimensions. type Size struct { Width int `json:"width"` Height int `json:"height"` } + +// ScreenPlacement describes a desired window position relative to a screen. +// +// p := screen.ScreenPlacement{ScreenID: "1", X: 100, Y: 200, Width: 800, Height: 600} +// p.Apply(platformWindow) +type ScreenPlacement struct { + ScreenID string `json:"screenId"` + X int `json:"x"` + Y int `json:"y"` + Width int `json:"width"` + Height int `json:"height"` +} + +// Placer is implemented by platform windows that can be repositioned. +type Placer interface { + SetPosition(x, y int) + SetSize(width, height int) +} + +// Apply positions and sizes the given Placer according to the placement. +// +// placement.Apply(pw) +func (p ScreenPlacement) Apply(target Placer) { + if p.Width > 0 && p.Height > 0 { + target.SetSize(p.Width, p.Height) + } + target.SetPosition(p.X, p.Y) +} diff --git a/pkg/screen/service.go b/pkg/screen/service.go index 87b22ae6..b0e51542 100644 --- a/pkg/screen/service.go +++ b/pkg/screen/service.go @@ -40,6 +40,8 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) { return s.platform.GetAll(), true, nil case QueryPrimary: return s.platform.GetPrimary(), true, nil + case QueryCurrent: + return s.platform.GetCurrent(), true, nil case QueryByID: return s.queryByID(q.ID), true, nil case QueryAtPoint: diff --git a/pkg/screen/service_test.go b/pkg/screen/service_test.go index 56c08335..7dcb93e7 100644 --- a/pkg/screen/service_test.go +++ b/pkg/screen/service_test.go @@ -12,6 +12,7 @@ import ( type mockPlatform struct { screens []Screen + current *Screen } func (m *mockPlatform) GetAll() []Screen { return m.screens } @@ -23,6 +24,7 @@ func (m *mockPlatform) GetPrimary() *Screen { } return nil } +func (m *mockPlatform) GetCurrent() *Screen { return m.current } func newTestService(t *testing.T) (*mockPlatform, *core.Core) { t.Helper() @@ -130,3 +132,127 @@ func TestQueryWorkAreas_Good(t *testing.T) { assert.Len(t, areas, 2) assert.Equal(t, 38, areas[0].Y) // primary has menu bar offset } + +// --- QueryCurrent --- + +func TestQueryCurrent_Good(t *testing.T) { + mock, c := newTestService(t) + mock.current = &mock.screens[1] // set "External" as current + + result, handled, err := c.QUERY(QueryCurrent{}) + require.NoError(t, err) + assert.True(t, handled) + scr := result.(*Screen) + require.NotNil(t, scr) + assert.Equal(t, "External", scr.Name) +} + +func TestQueryCurrent_Bad_NilWhenNoCurrentScreen(t *testing.T) { + // current is nil by default + _, c := newTestService(t) + result, handled, err := c.QUERY(QueryCurrent{}) + require.NoError(t, err) + assert.True(t, handled) + assert.Nil(t, result) +} + +func TestQueryCurrent_Ugly_NoServiceRegistered(t *testing.T) { + c, err := core.New(core.WithServiceLock()) + require.NoError(t, err) + _, handled, _ := c.QUERY(QueryCurrent{}) + assert.False(t, handled) +} + +// --- Rect geometry helpers --- + +func TestRect_Contains_Good(t *testing.T) { + r := Rect{X: 100, Y: 100, Width: 200, Height: 150} + assert.True(t, r.Contains(100, 100)) // top-left corner (inclusive) + assert.True(t, r.Contains(200, 175)) // centre + assert.True(t, r.Contains(299, 249)) // bottom-right corner (exclusive boundary - 1) +} + +func TestRect_Contains_Bad(t *testing.T) { + r := Rect{X: 100, Y: 100, Width: 200, Height: 150} + assert.False(t, r.Contains(99, 100)) // just left + assert.False(t, r.Contains(100, 99)) // just above + assert.False(t, r.Contains(300, 200)) // right boundary (exclusive) + assert.False(t, r.Contains(200, 250)) // bottom boundary (exclusive) +} + +func TestRect_Center_Good(t *testing.T) { + r := Rect{X: 0, Y: 0, Width: 200, Height: 100} + cx, cy := r.Center() + assert.Equal(t, 100, cx) + assert.Equal(t, 50, cy) +} + +func TestRect_Center_Ugly_OddDimensions(t *testing.T) { + r := Rect{X: 1, Y: 1, Width: 101, Height: 51} + cx, cy := r.Center() + assert.Equal(t, 51, cx) // integer division: 1 + 101/2 = 1 + 50 = 51 + assert.Equal(t, 26, cy) // 1 + 51/2 = 1 + 25 = 26 +} + +func TestRect_Overlaps_Good(t *testing.T) { + a := Rect{X: 0, Y: 0, Width: 200, Height: 200} + b := Rect{X: 100, Y: 100, Width: 200, Height: 200} + assert.True(t, a.Overlaps(b)) + assert.True(t, b.Overlaps(a)) +} + +func TestRect_Overlaps_Bad(t *testing.T) { + a := Rect{X: 0, Y: 0, Width: 100, Height: 100} + b := Rect{X: 200, Y: 200, Width: 100, Height: 100} + assert.False(t, a.Overlaps(b)) +} + +func TestRect_Overlaps_Ugly_AdjacentEdge(t *testing.T) { + // touching at edge — not overlapping (exclusive right/bottom boundary) + a := Rect{X: 0, Y: 0, Width: 100, Height: 100} + b := Rect{X: 100, Y: 0, Width: 100, Height: 100} + assert.False(t, a.Overlaps(b)) +} + +// --- ScreenPlacement --- + +type mockPlacer struct { + x, y int + width, height int +} + +func (m *mockPlacer) SetPosition(x, y int) { m.x = x; m.y = y } +func (m *mockPlacer) SetSize(width, height int) { m.width = width; m.height = height } + +func TestScreenPlacement_Apply_Good(t *testing.T) { + p := ScreenPlacement{ScreenID: "1", X: 50, Y: 75, Width: 800, Height: 600} + placer := &mockPlacer{} + p.Apply(placer) + assert.Equal(t, 50, placer.x) + assert.Equal(t, 75, placer.y) + assert.Equal(t, 800, placer.width) + assert.Equal(t, 600, placer.height) +} + +func TestScreenPlacement_Apply_Bad_ZeroDimensions(t *testing.T) { + // Zero dimensions should skip SetSize but still call SetPosition + p := ScreenPlacement{ScreenID: "1", X: 100, Y: 200, Width: 0, Height: 0} + placer := &mockPlacer{width: 1280, height: 800} + p.Apply(placer) + assert.Equal(t, 100, placer.x) + assert.Equal(t, 200, placer.y) + // Size should remain unchanged when both dimensions are zero + assert.Equal(t, 1280, placer.width) + assert.Equal(t, 800, placer.height) +} + +func TestScreenPlacement_Apply_Ugly_NegativeCoords(t *testing.T) { + // Negative coordinates are valid (multi-monitor setups with negative origin) + p := ScreenPlacement{ScreenID: "2", X: -1920, Y: 0, Width: 1920, Height: 1080} + placer := &mockPlacer{} + p.Apply(placer) + assert.Equal(t, -1920, placer.x) + assert.Equal(t, 0, placer.y) + assert.Equal(t, 1920, placer.width) + assert.Equal(t, 1080, placer.height) +} diff --git a/pkg/window/messages.go b/pkg/window/messages.go index ef637178..92ce6b0a 100644 --- a/pkg/window/messages.go +++ b/pkg/window/messages.go @@ -102,6 +102,52 @@ type TaskApplyWorkflow struct { type TaskSaveConfig struct{ Config map[string]any } +// QueryWindowZoom queries the current zoom factor for a named window. Result: float64 +type QueryWindowZoom struct{ Name string } + +// QueryWindowBounds queries the current bounds for a named window. Result: *Bounds +type QueryWindowBounds struct{ Name string } + +// TaskSetZoom sets the zoom factor for a named window. +// c.PERFORM(window.TaskSetZoom{Name: "main", Factor: 1.5}) +type TaskSetZoom struct { + Name string + Factor float64 +} + +// TaskSetURL navigates a named window to a new URL. +// c.PERFORM(window.TaskSetURL{Name: "main", URL: "/settings"}) +type TaskSetURL struct { + Name string + URL string +} + +// TaskSetHTML replaces the content of a named window with HTML. +// c.PERFORM(window.TaskSetHTML{Name: "main", HTML: "

Hello

"}) +type TaskSetHTML struct { + Name string + HTML string +} + +// TaskExecJS evaluates JavaScript in a named window. +// c.PERFORM(window.TaskExecJS{Name: "main", JS: "document.title = 'Updated'"}) +type TaskExecJS struct { + Name string + JS string +} + +// TaskToggleFullscreen toggles fullscreen on a named window. +type TaskToggleFullscreen struct{ Name string } + +// TaskPrint triggers the platform print dialog for a named window. +type TaskPrint struct{ Name string } + +// TaskFlash flashes (or stops flashing) the taskbar entry for a named window (Windows). +type TaskFlash struct { + Name string + Enabled bool +} + type ActionWindowOpened struct{ Name string } type ActionWindowClosed struct{ Name string } diff --git a/pkg/window/mock_platform.go b/pkg/window/mock_platform.go index 762f9d4a..941adf13 100644 --- a/pkg/window/mock_platform.go +++ b/pkg/window/mock_platform.go @@ -29,14 +29,20 @@ func (m *MockPlatform) GetWindows() []PlatformWindow { } type MockWindow struct { - name, title, url string - width, height, x, y int - maximised, focused bool - visible, alwaysOnTop bool - backgroundColour [4]uint8 - closed bool - eventHandlers []func(WindowEvent) - fileDropHandlers []func(paths []string, targetID string) + name, title, url string + width, height, x, y int + maximised, focused bool + visible, alwaysOnTop bool + backgroundColour [4]uint8 + closed bool + zoom float64 + html string + lastJS string + flashing bool + printCalled bool + toggleFullscreenCount int + eventHandlers []func(WindowEvent) + fileDropHandlers []func(paths []string, targetID string) } func (w *MockWindow) Name() string { return w.name } @@ -60,6 +66,25 @@ 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) GetZoom() float64 { return w.zoom } +func (w *MockWindow) SetZoom(factor float64) { w.zoom = factor } +func (w *MockWindow) ZoomIn() { w.zoom += 0.1 } +func (w *MockWindow) ZoomOut() { w.zoom -= 0.1 } +func (w *MockWindow) SetURL(url string) { w.url = url } +func (w *MockWindow) SetHTML(html string) { w.html = html } +func (w *MockWindow) ExecJS(js string) { w.lastJS = js } +func (w *MockWindow) GetBounds() Bounds { + return Bounds{X: w.x, Y: w.y, Width: w.width, Height: w.height} +} +func (w *MockWindow) SetBounds(bounds Bounds) { + w.x = bounds.X + w.y = bounds.Y + w.width = bounds.Width + w.height = bounds.Height +} +func (w *MockWindow) ToggleFullscreen() { w.toggleFullscreenCount++ } +func (w *MockWindow) Print() error { w.printCalled = true; return nil } +func (w *MockWindow) Flash(enabled bool) { w.flashing = enabled } func (w *MockWindow) OnWindowEvent(handler func(WindowEvent)) { w.eventHandlers = append(w.eventHandlers, handler) } diff --git a/pkg/window/mock_test.go b/pkg/window/mock_test.go index a932756f..046338ff 100644 --- a/pkg/window/mock_test.go +++ b/pkg/window/mock_test.go @@ -27,16 +27,22 @@ func (m *mockPlatform) GetWindows() []PlatformWindow { } type mockWindow struct { - name, title, url string - width, height, x, y int - maximised, focused bool - visible, alwaysOnTop bool - backgroundColour [4]uint8 - closed bool - minimised bool - fullscreened bool - eventHandlers []func(WindowEvent) - fileDropHandlers []func(paths []string, targetID string) + name, title, url string + width, height, x, y int + maximised, focused bool + visible, alwaysOnTop bool + backgroundColour [4]uint8 + closed bool + minimised bool + fullscreened bool + zoom float64 + html string + lastJS string + flashing bool + printCalled bool + toggleFullscreenCount int + eventHandlers []func(WindowEvent) + fileDropHandlers []func(paths []string, targetID string) } func (w *mockWindow) Name() string { return w.name } @@ -60,6 +66,25 @@ 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) GetZoom() float64 { return w.zoom } +func (w *mockWindow) SetZoom(factor float64) { w.zoom = factor } +func (w *mockWindow) ZoomIn() { w.zoom += 0.1 } +func (w *mockWindow) ZoomOut() { w.zoom -= 0.1 } +func (w *mockWindow) SetURL(url string) { w.url = url } +func (w *mockWindow) SetHTML(html string) { w.html = html } +func (w *mockWindow) ExecJS(js string) { w.lastJS = js } +func (w *mockWindow) GetBounds() Bounds { + return Bounds{X: w.x, Y: w.y, Width: w.width, Height: w.height} +} +func (w *mockWindow) SetBounds(bounds Bounds) { + w.x = bounds.X + w.y = bounds.Y + w.width = bounds.Width + w.height = bounds.Height +} +func (w *mockWindow) ToggleFullscreen() { w.toggleFullscreenCount++ } +func (w *mockWindow) Print() error { w.printCalled = true; return nil } +func (w *mockWindow) Flash(enabled bool) { w.flashing = enabled } func (w *mockWindow) OnWindowEvent(handler func(WindowEvent)) { w.eventHandlers = append(w.eventHandlers, handler) } diff --git a/pkg/window/platform.go b/pkg/window/platform.go index c0e56a98..24daee0f 100644 --- a/pkg/window/platform.go +++ b/pkg/window/platform.go @@ -55,6 +55,26 @@ type PlatformWindow interface { Fullscreen() UnFullscreen() + // Zoom + GetZoom() float64 + SetZoom(factor float64) + ZoomIn() + ZoomOut() + + // Content + SetURL(url string) + SetHTML(html string) + ExecJS(js string) + + // Bounds + GetBounds() Bounds + SetBounds(bounds Bounds) + + // Extras + ToggleFullscreen() + Print() error + Flash(enabled bool) + // Events OnWindowEvent(handler func(event WindowEvent)) @@ -62,6 +82,14 @@ type PlatformWindow interface { OnFileDrop(handler func(paths []string, targetID string)) } +// Bounds holds the position and dimensions of a window. +type Bounds struct { + X int `json:"x"` + Y int `json:"y"` + Width int `json:"width"` + Height int `json:"height"` +} + // WindowEvent is emitted by the backend for window state changes. type WindowEvent struct { Type string // "focus", "blur", "move", "resize", "close" diff --git a/pkg/window/service.go b/pkg/window/service.go index 731929af..90061412 100644 --- a/pkg/window/service.go +++ b/pkg/window/service.go @@ -59,6 +59,10 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) { return s.queryWindowList(), true, nil case QueryWindowByName: return s.queryWindowByName(q.Name), true, nil + case QueryWindowZoom: + return s.queryWindowZoom(q.Name) + case QueryWindowBounds: + return s.queryWindowBounds(q.Name) case QueryLayoutList: return s.manager.Layout().ListLayouts(), true, nil case QueryLayoutGet: @@ -89,6 +93,23 @@ func (s *Service) queryWindowList() []WindowInfo { return result } +func (s *Service) queryWindowZoom(name string) (any, bool, error) { + pw, ok := s.manager.Get(name) + if !ok { + return nil, true, coreerr.E("window.queryWindowZoom", "window not found: "+name, nil) + } + return pw.GetZoom(), true, nil +} + +func (s *Service) queryWindowBounds(name string) (any, bool, error) { + pw, ok := s.manager.Get(name) + if !ok { + return nil, true, coreerr.E("window.queryWindowBounds", "window not found: "+name, nil) + } + bounds := pw.GetBounds() + return &bounds, true, nil +} + func (s *Service) queryWindowByName(name string) *WindowInfo { pw, ok := s.manager.Get(name) if !ok { @@ -133,6 +154,20 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { return nil, true, s.taskSetVisibility(t.Name, t.Visible) case TaskFullscreen: return nil, true, s.taskFullscreen(t.Name, t.Fullscreen) + case TaskSetZoom: + return nil, true, s.taskSetZoom(t.Name, t.Factor) + case TaskSetURL: + return nil, true, s.taskSetURL(t.Name, t.URL) + case TaskSetHTML: + return nil, true, s.taskSetHTML(t.Name, t.HTML) + case TaskExecJS: + return nil, true, s.taskExecJS(t.Name, t.JS) + case TaskToggleFullscreen: + return nil, true, s.taskToggleFullscreen(t.Name) + case TaskPrint: + return nil, true, s.taskPrint(t.Name) + case TaskFlash: + return nil, true, s.taskFlash(t.Name, t.Enabled) case TaskSaveLayout: return nil, true, s.taskSaveLayout(t.Name) case TaskRestoreLayout: @@ -359,6 +394,68 @@ func (s *Service) taskFullscreen(name string, fullscreen bool) error { return nil } +func (s *Service) taskSetZoom(name string, factor float64) error { + pw, ok := s.manager.Get(name) + if !ok { + return coreerr.E("window.taskSetZoom", "window not found: "+name, nil) + } + pw.SetZoom(factor) + return nil +} + +func (s *Service) taskSetURL(name, url string) error { + pw, ok := s.manager.Get(name) + if !ok { + return coreerr.E("window.taskSetURL", "window not found: "+name, nil) + } + pw.SetURL(url) + return nil +} + +func (s *Service) taskSetHTML(name, html string) error { + pw, ok := s.manager.Get(name) + if !ok { + return coreerr.E("window.taskSetHTML", "window not found: "+name, nil) + } + pw.SetHTML(html) + return nil +} + +func (s *Service) taskExecJS(name, js string) error { + pw, ok := s.manager.Get(name) + if !ok { + return coreerr.E("window.taskExecJS", "window not found: "+name, nil) + } + pw.ExecJS(js) + return nil +} + +func (s *Service) taskToggleFullscreen(name string) error { + pw, ok := s.manager.Get(name) + if !ok { + return coreerr.E("window.taskToggleFullscreen", "window not found: "+name, nil) + } + pw.ToggleFullscreen() + return nil +} + +func (s *Service) taskPrint(name string) error { + pw, ok := s.manager.Get(name) + if !ok { + return coreerr.E("window.taskPrint", "window not found: "+name, nil) + } + return pw.Print() +} + +func (s *Service) taskFlash(name string, enabled bool) error { + pw, ok := s.manager.Get(name) + if !ok { + return coreerr.E("window.taskFlash", "window not found: "+name, nil) + } + pw.Flash(enabled) + return nil +} + func (s *Service) taskSaveLayout(name string) error { windows := s.queryWindowList() states := make(map[string]WindowState, len(windows)) diff --git a/pkg/window/service_screen_test.go b/pkg/window/service_screen_test.go index af86b81b..00c511eb 100644 --- a/pkg/window/service_screen_test.go +++ b/pkg/window/service_screen_test.go @@ -25,6 +25,10 @@ func (m *mockScreenPlatform) GetPrimary() *screen.Screen { return nil } +func (m *mockScreenPlatform) GetCurrent() *screen.Screen { + return m.GetPrimary() +} + func newTestWindowServiceWithScreen(t *testing.T, screens []screen.Screen) (*Service, *core.Core) { t.Helper() diff --git a/pkg/window/service_test.go b/pkg/window/service_test.go index 3aa33f29..76d58c69 100644 --- a/pkg/window/service_test.go +++ b/pkg/window/service_test.go @@ -522,3 +522,203 @@ func TestTaskApplyWorkflow_Good(t *testing.T) { assert.Equal(t, 960, x) assert.Equal(t, 0, y) } + +// --- TaskSetZoom / QueryWindowZoom --- + +func TestTaskSetZoom_Good(t *testing.T) { + svc, c := newTestWindowService(t) + _ = requireOpenWindow(t, c, Window{Name: "test"}) + + _, handled, err := c.PERFORM(TaskSetZoom{Name: "test", Factor: 1.5}) + require.NoError(t, err) + assert.True(t, handled) + + result, handled, err := c.QUERY(QueryWindowZoom{Name: "test"}) + require.NoError(t, err) + assert.True(t, handled) + assert.InDelta(t, 1.5, result.(float64), 0.001) + + pw, ok := svc.Manager().Get("test") + require.True(t, ok) + mw := pw.(*mockWindow) + assert.InDelta(t, 1.5, mw.zoom, 0.001) +} + +func TestTaskSetZoom_Bad(t *testing.T) { + _, c := newTestWindowService(t) + _, handled, err := c.PERFORM(TaskSetZoom{Name: "nonexistent", Factor: 1.2}) + assert.True(t, handled) + assert.Error(t, err) +} + +func TestQueryWindowZoom_Bad(t *testing.T) { + _, c := newTestWindowService(t) + _, handled, err := c.QUERY(QueryWindowZoom{Name: "nonexistent"}) + assert.True(t, handled) + assert.Error(t, err) +} + +// --- QueryWindowBounds --- + +func TestQueryWindowBounds_Good(t *testing.T) { + _, c := newTestWindowService(t) + _ = requireOpenWindow(t, c, Window{Name: "bounds-test", Width: 800, Height: 600, X: 50, Y: 75}) + + result, handled, err := c.QUERY(QueryWindowBounds{Name: "bounds-test"}) + require.NoError(t, err) + assert.True(t, handled) + bounds := result.(*Bounds) + require.NotNil(t, bounds) + assert.Equal(t, 50, bounds.X) + assert.Equal(t, 75, bounds.Y) + assert.Equal(t, 800, bounds.Width) + assert.Equal(t, 600, bounds.Height) +} + +func TestQueryWindowBounds_Bad(t *testing.T) { + _, c := newTestWindowService(t) + _, handled, err := c.QUERY(QueryWindowBounds{Name: "nonexistent"}) + assert.True(t, handled) + assert.Error(t, err) +} + +// --- TaskSetURL --- + +func TestTaskSetURL_Good(t *testing.T) { + svc, c := newTestWindowService(t) + _ = requireOpenWindow(t, c, Window{Name: "test"}) + + _, handled, err := c.PERFORM(TaskSetURL{Name: "test", URL: "/settings"}) + require.NoError(t, err) + assert.True(t, handled) + + pw, ok := svc.Manager().Get("test") + require.True(t, ok) + mw := pw.(*mockWindow) + assert.Equal(t, "/settings", mw.url) +} + +func TestTaskSetURL_Bad(t *testing.T) { + _, c := newTestWindowService(t) + _, handled, err := c.PERFORM(TaskSetURL{Name: "nonexistent", URL: "/nope"}) + assert.True(t, handled) + assert.Error(t, err) +} + +// --- TaskSetHTML --- + +func TestTaskSetHTML_Good(t *testing.T) { + svc, c := newTestWindowService(t) + _ = requireOpenWindow(t, c, Window{Name: "test"}) + + _, handled, err := c.PERFORM(TaskSetHTML{Name: "test", HTML: "

Hello

"}) + require.NoError(t, err) + assert.True(t, handled) + + pw, ok := svc.Manager().Get("test") + require.True(t, ok) + mw := pw.(*mockWindow) + assert.Equal(t, "

Hello

", mw.html) +} + +func TestTaskSetHTML_Bad(t *testing.T) { + _, c := newTestWindowService(t) + _, handled, err := c.PERFORM(TaskSetHTML{Name: "nonexistent", HTML: "

nope

"}) + assert.True(t, handled) + assert.Error(t, err) +} + +// --- TaskExecJS --- + +func TestTaskExecJS_Good(t *testing.T) { + svc, c := newTestWindowService(t) + _ = requireOpenWindow(t, c, Window{Name: "test"}) + + _, handled, err := c.PERFORM(TaskExecJS{Name: "test", JS: "document.title = 'Updated'"}) + require.NoError(t, err) + assert.True(t, handled) + + pw, ok := svc.Manager().Get("test") + require.True(t, ok) + mw := pw.(*mockWindow) + assert.Equal(t, "document.title = 'Updated'", mw.lastJS) +} + +func TestTaskExecJS_Bad(t *testing.T) { + _, c := newTestWindowService(t) + _, handled, err := c.PERFORM(TaskExecJS{Name: "nonexistent", JS: "alert(1)"}) + assert.True(t, handled) + assert.Error(t, err) +} + +// --- TaskToggleFullscreen --- + +func TestTaskToggleFullscreen_Good(t *testing.T) { + svc, c := newTestWindowService(t) + _ = requireOpenWindow(t, c, Window{Name: "test"}) + + _, handled, err := c.PERFORM(TaskToggleFullscreen{Name: "test"}) + require.NoError(t, err) + assert.True(t, handled) + + pw, ok := svc.Manager().Get("test") + require.True(t, ok) + mw := pw.(*mockWindow) + assert.Equal(t, 1, mw.toggleFullscreenCount) +} + +func TestTaskToggleFullscreen_Bad(t *testing.T) { + _, c := newTestWindowService(t) + _, handled, err := c.PERFORM(TaskToggleFullscreen{Name: "nonexistent"}) + assert.True(t, handled) + assert.Error(t, err) +} + +// --- TaskPrint --- + +func TestTaskPrint_Good(t *testing.T) { + svc, c := newTestWindowService(t) + _ = requireOpenWindow(t, c, Window{Name: "test"}) + + _, handled, err := c.PERFORM(TaskPrint{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.printCalled) +} + +func TestTaskPrint_Bad(t *testing.T) { + _, c := newTestWindowService(t) + _, handled, err := c.PERFORM(TaskPrint{Name: "nonexistent"}) + assert.True(t, handled) + assert.Error(t, err) +} + +// --- TaskFlash --- + +func TestTaskFlash_Good(t *testing.T) { + svc, c := newTestWindowService(t) + _ = requireOpenWindow(t, c, Window{Name: "test"}) + + _, handled, err := c.PERFORM(TaskFlash{Name: "test", Enabled: 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.flashing) + + _, _, _ = c.PERFORM(TaskFlash{Name: "test", Enabled: false}) + assert.False(t, mw.flashing) +} + +func TestTaskFlash_Bad(t *testing.T) { + _, c := newTestWindowService(t) + _, handled, err := c.PERFORM(TaskFlash{Name: "nonexistent", Enabled: true}) + assert.True(t, handled) + assert.Error(t, err) +} diff --git a/pkg/window/wails.go b/pkg/window/wails.go index a2587ce9..aa4f06f6 100644 --- a/pkg/window/wails.go +++ b/pkg/window/wails.go @@ -85,8 +85,25 @@ func (ww *wailsWindow) Focus() { ww.w.Focus() } func (ww *wailsWindow) Close() { ww.w.Close() } func (ww *wailsWindow) Show() { ww.w.Show() } func (ww *wailsWindow) Hide() { ww.w.Hide() } -func (ww *wailsWindow) Fullscreen() { ww.w.Fullscreen() } -func (ww *wailsWindow) UnFullscreen() { ww.w.UnFullscreen() } +func (ww *wailsWindow) Fullscreen() { ww.w.Fullscreen() } +func (ww *wailsWindow) UnFullscreen() { ww.w.UnFullscreen() } +func (ww *wailsWindow) GetZoom() float64 { return ww.w.GetZoom() } +func (ww *wailsWindow) SetZoom(factor float64) { ww.w.SetZoom(factor) } +func (ww *wailsWindow) ZoomIn() { ww.w.ZoomIn() } +func (ww *wailsWindow) ZoomOut() { ww.w.ZoomOut() } +func (ww *wailsWindow) SetURL(url string) { ww.w.SetURL(url) } +func (ww *wailsWindow) SetHTML(html string) { ww.w.SetHTML(html) } +func (ww *wailsWindow) ExecJS(js string) { ww.w.ExecJS(js) } +func (ww *wailsWindow) GetBounds() Bounds { + r := ww.w.Bounds() + return Bounds{X: r.X, Y: r.Y, Width: r.Width, Height: r.Height} +} +func (ww *wailsWindow) SetBounds(b Bounds) { + ww.w.SetBounds(application.Rect{X: b.X, Y: b.Y, Width: b.Width, Height: b.Height}) +} +func (ww *wailsWindow) ToggleFullscreen() { ww.w.ToggleFullscreen() } +func (ww *wailsWindow) Print() error { return ww.w.Print() } +func (ww *wailsWindow) Flash(enabled bool) { ww.w.Flash(enabled) } func (ww *wailsWindow) OnWindowEvent(handler func(event WindowEvent)) { name := ww.w.Name() From 479537d12cdef430cd503f0455eb176c58554aad Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Apr 2026 14:18:05 +0100 Subject: [PATCH 12/12] feat(gui): theme override, clipboard images, notifications, tray, layout helpers Pass from codex implementing RFC spec gaps: theme_set IPC + state, clipboard image read/write + MCP exposure, interactive notifications and clearing, real tray tooltip/label/menu/message tasks, layout heuristics (layout_suggest, layout_beside_editor, screen_find_space, window_arrange_pair), dialog_message, focus_set, screen_work_area. Co-Authored-By: Virgil --- pkg/clipboard/messages.go | 6 + pkg/clipboard/platform.go | 12 ++ pkg/clipboard/service.go | 20 +++- pkg/clipboard/service_test.go | 26 ++++- pkg/display/FEATURES.md | 32 ++--- pkg/environment/messages.go | 5 + pkg/environment/service.go | 35 +++++- pkg/environment/service_test.go | 40 ++++++- pkg/mcp/layout_helpers.go | 195 +++++++++++++++++++++++++++++++ pkg/mcp/mcp_test.go | 99 +++++++++++++++- pkg/mcp/tools_clipboard.go | 50 ++++++++ pkg/mcp/tools_dialog.go | 30 +++++ pkg/mcp/tools_environment.go | 18 +++ pkg/mcp/tools_layout.go | 177 ++++++++++++++++++++++++++++ pkg/mcp/tools_notification.go | 44 +++++++ pkg/mcp/tools_screen.go | 71 +++++++++++ pkg/mcp/tools_tray.go | 90 +++++++++++++- pkg/mcp/tools_window.go | 108 +++++++++++++++++ pkg/notification/messages.go | 3 + pkg/notification/platform.go | 17 ++- pkg/notification/service.go | 21 ++++ pkg/notification/service_test.go | 56 +++++++-- pkg/systray/messages.go | 9 ++ pkg/systray/mock_platform.go | 13 ++- pkg/systray/mock_test.go | 7 ++ pkg/systray/platform.go | 1 + pkg/systray/service.go | 6 + pkg/systray/service_test.go | 32 +++++ pkg/systray/tray.go | 8 ++ pkg/systray/wails.go | 6 + 30 files changed, 1186 insertions(+), 51 deletions(-) create mode 100644 pkg/mcp/layout_helpers.go diff --git a/pkg/clipboard/messages.go b/pkg/clipboard/messages.go index 29f29de5..052e8593 100644 --- a/pkg/clipboard/messages.go +++ b/pkg/clipboard/messages.go @@ -4,8 +4,14 @@ package clipboard // QueryText reads the clipboard. Result: ClipboardContent type QueryText struct{} +// QueryImage reads image data from the clipboard. Result: ImageContent +type QueryImage struct{} + // TaskSetText writes text to the clipboard. Result: bool (success) type TaskSetText struct{ Text string } +// TaskSetImage writes image data to the clipboard. Result: bool (success) +type TaskSetImage struct{ Data []byte } + // TaskClear clears the clipboard. Result: bool (success) type TaskClear struct{} diff --git a/pkg/clipboard/platform.go b/pkg/clipboard/platform.go index 1857cfd3..deb2f6ac 100644 --- a/pkg/clipboard/platform.go +++ b/pkg/clipboard/platform.go @@ -7,8 +7,20 @@ type Platform interface { SetText(text string) bool } +// ImagePlatform is an optional extension for clipboard backends that support images. +type ImagePlatform interface { + Image() ([]byte, bool) + SetImage(data []byte) bool +} + // ClipboardContent is the result of QueryText. type ClipboardContent struct { Text string `json:"text"` HasContent bool `json:"hasContent"` } + +// ImageContent is the result of QueryImage. +type ImageContent struct { + Data []byte `json:"data"` + HasImage bool `json:"hasImage"` +} diff --git a/pkg/clipboard/service.go b/pkg/clipboard/service.go index ee47af6e..196e6c6c 100644 --- a/pkg/clipboard/service.go +++ b/pkg/clipboard/service.go @@ -4,6 +4,7 @@ package clipboard import ( "context" + coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/go/pkg/core" ) @@ -40,6 +41,13 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) { case QueryText: text, ok := s.platform.Text() return ClipboardContent{Text: text, HasContent: ok && text != ""}, true, nil + case QueryImage: + imgPlatform, ok := s.platform.(ImagePlatform) + if !ok { + return ImageContent{}, true, nil + } + data, hasImage := imgPlatform.Image() + return ImageContent{Data: data, HasImage: hasImage && len(data) > 0}, true, nil default: return nil, false, nil } @@ -49,8 +57,18 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { switch t := t.(type) { case TaskSetText: return s.platform.SetText(t.Text), true, nil + case TaskSetImage: + imgPlatform, ok := s.platform.(ImagePlatform) + if !ok { + return nil, true, coreerr.E("clipboard.handleTask", "clipboard image operations are not supported by this platform", nil) + } + return imgPlatform.SetImage(t.Data), true, nil case TaskClear: - return s.platform.SetText(""), true, nil + success := s.platform.SetText("") + if imgPlatform, ok := s.platform.(ImagePlatform); ok { + success = imgPlatform.SetImage(nil) && success + } + return success, true, nil default: return nil, false, nil } diff --git a/pkg/clipboard/service_test.go b/pkg/clipboard/service_test.go index 63677df7..c7ee3c8f 100644 --- a/pkg/clipboard/service_test.go +++ b/pkg/clipboard/service_test.go @@ -11,8 +11,10 @@ import ( ) type mockPlatform struct { - text string - ok bool + text string + ok bool + image []byte + hasImage bool } func (m *mockPlatform) Text() (string, bool) { return m.text, m.ok } @@ -21,6 +23,12 @@ func (m *mockPlatform) SetText(text string) bool { m.ok = text != "" return true } +func (m *mockPlatform) Image() ([]byte, bool) { return m.image, m.hasImage } +func (m *mockPlatform) SetImage(data []byte) bool { + m.image = append([]byte(nil), data...) + m.hasImage = len(data) > 0 + return true +} func newTestService(t *testing.T) (*Service, *core.Core) { t.Helper() @@ -79,3 +87,17 @@ func TestTaskClear_Good(t *testing.T) { assert.Equal(t, "", r.(ClipboardContent).Text) assert.False(t, r.(ClipboardContent).HasContent) } + +func TestQueryImage_Good(t *testing.T) { + _, c := newTestService(t) + _, handled, err := c.PERFORM(TaskSetImage{Data: []byte{0x89, 0x50, 0x4e, 0x47}}) + require.NoError(t, err) + assert.True(t, handled) + + result, handled, err := c.QUERY(QueryImage{}) + require.NoError(t, err) + assert.True(t, handled) + content := result.(ImageContent) + assert.True(t, content.HasImage) + assert.Equal(t, []byte{0x89, 0x50, 0x4e, 0x47}, content.Data) +} diff --git a/pkg/display/FEATURES.md b/pkg/display/FEATURES.md index f336a61f..474e2bee 100644 --- a/pkg/display/FEATURES.md +++ b/pkg/display/FEATURES.md @@ -59,13 +59,13 @@ This document tracks the implementation of display server features that enable A ### Smart Layout - [x] `layout_tile` - Auto-tile windows (left/right/top/bottom/quadrants/grid) - [x] `layout_stack` - Stack windows in cascade pattern -- [ ] `layout_beside_editor` - Position window beside detected IDE window -- [ ] `layout_suggest` - Given screen dimensions, suggest optimal arrangement +- [x] `layout_beside_editor` - Position window beside detected IDE window +- [x] `layout_suggest` - Given screen dimensions, suggest optimal arrangement - [x] `layout_snap` - Snap window to screen edge/corner/center ### AI-Optimized Layout -- [ ] `screen_find_space` - Find empty screen space for new window -- [ ] `window_arrange_pair` - Put two windows side-by-side optimally +- [x] `screen_find_space` - Find empty screen space for new window +- [x] `window_arrange_pair` - Put two windows side-by-side optimally - [x] `layout_workflow` - Preset layouts: "coding", "debugging", "presenting", "side-by-side" --- @@ -124,8 +124,8 @@ This document tracks the implementation of display server features that enable A ### Clipboard - [x] `clipboard_read` - Read clipboard text content - [x] `clipboard_write` - Write text to clipboard -- [ ] `clipboard_read_image` - Read image from clipboard -- [ ] `clipboard_write_image` - Write image to clipboard +- [x] `clipboard_read_image` - Read image from clipboard +- [x] `clipboard_write_image` - Write image to clipboard - [x] `clipboard_has` - Check clipboard content type - [x] `clipboard_clear` - Clear clipboard contents @@ -133,8 +133,8 @@ This document tracks the implementation of display server features that enable A - [x] `notification_show` - Show native system notification (macOS/Windows/Linux) - [x] `notification_permission_request` - Request notification permission - [x] `notification_permission_check` - Check notification authorization status -- [ ] `notification_clear` - Clear notifications -- [ ] `notification_with_actions` - Interactive notifications with buttons +- [x] `notification_clear` - Clear notifications +- [x] `notification_with_actions` - Interactive notifications with buttons ### Dialogs - [x] `dialog_open_file` - Show file open dialog @@ -146,7 +146,7 @@ This document tracks the implementation of display server features that enable A ### Theme & Appearance - [x] `theme_get` - Get current theme (dark/light) -- [ ] `theme_set` - Set application theme +- [x] `theme_set` - Set application theme - [x] `theme_system` - Get system theme preference - [x] `theme_on_change` - Subscribe to theme changes (via WebSocket events) @@ -173,7 +173,7 @@ This document tracks the implementation of display server features that enable A - [x] `tray_set_label` - Set tray label text - [x] `tray_set_menu` - Set tray menu items (with nested submenus) - [x] `tray_info` - Get tray status info -- [ ] `tray_show_message` - Show tray balloon notification +- [x] `tray_show_message` - Show tray balloon notification --- @@ -193,7 +193,7 @@ This document tracks the implementation of display server features that enable A ### Phase 3 - Layouts (DONE) - [x] layout_save, layout_restore, layout_list - [x] layout_delete, layout_get -- [ ] layout_tile, layout_beside_editor (future) +- [x] layout_tile, layout_beside_editor ### Phase 4 - WebView Debug (DONE) - [x] webview_screenshot, webview_screenshot_element @@ -202,7 +202,7 @@ This document tracks the implementation of display server features that enable A - [x] webview_scroll, webview_hover, webview_select, webview_check - [x] webview_highlight, webview_errors - [x] webview_performance, webview_resources -- [ ] webview_network, webview_devtools (future) +- [~] webview_network complete; webview_devtools pending ### Phase 5 - System Integration (DONE) - [x] clipboard_read, clipboard_write, clipboard_has, clipboard_clear @@ -236,8 +236,8 @@ This document tracks the implementation of display server features that enable A ### Phase 8 - Remaining Features (Future) - [ ] window_opacity (true opacity if Wails adds support) -- [ ] layout_beside_editor, layout_suggest +- [x] layout_beside_editor, layout_suggest - [ ] webview_devtools_open, webview_devtools_close -- [ ] clipboard_read_image, clipboard_write_image -- [ ] notification_with_actions, notification_clear -- [ ] tray_show_message - Balloon notifications +- [x] clipboard_read_image, clipboard_write_image +- [x] notification_with_actions, notification_clear +- [x] tray_show_message - Balloon notifications diff --git a/pkg/environment/messages.go b/pkg/environment/messages.go index 2beaeddc..b79a6730 100644 --- a/pkg/environment/messages.go +++ b/pkg/environment/messages.go @@ -10,6 +10,11 @@ type QueryInfo struct{} // QueryAccentColour returns the system accent colour. Result: string type QueryAccentColour struct{} +// TaskSetTheme overrides the application theme. Theme values: "light", "dark", "system". +type TaskSetTheme struct { + Theme string `json:"theme"` +} + // TaskOpenFileManager opens the system file manager. Result: error only type TaskOpenFileManager struct { Path string `json:"path"` diff --git a/pkg/environment/service.go b/pkg/environment/service.go index 6cc41923..6052a756 100644 --- a/pkg/environment/service.go +++ b/pkg/environment/service.go @@ -3,7 +3,9 @@ package environment import ( "context" + "strings" + coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/go/pkg/core" ) @@ -13,6 +15,7 @@ type Service struct { *core.ServiceRuntime[Options] platform Platform cancelTheme func() // returned by Platform.OnThemeChange — called on shutdown + override *bool } // Register(p) binds the environment service to a Core instance. @@ -32,6 +35,9 @@ func (s *Service) OnStartup(ctx context.Context) error { // Register theme change callback — broadcasts ActionThemeChanged via IPC s.cancelTheme = s.platform.OnThemeChange(func(isDark bool) { + if s.override != nil { + isDark = *s.override + } _ = s.Core().ACTION(ActionThemeChanged{IsDark: isDark}) }) return nil @@ -51,7 +57,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 QueryTheme: - isDark := s.platform.IsDarkMode() + isDark := s.currentThemeIsDark() theme := "light" if isDark { theme = "dark" @@ -70,9 +76,36 @@ 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 TaskSetTheme: + return nil, true, s.setThemeOverride(strings.ToLower(strings.TrimSpace(t.Theme))) case TaskOpenFileManager: return nil, true, s.platform.OpenFileManager(t.Path, t.Select) default: return nil, false, nil } } + +func (s *Service) currentThemeIsDark() bool { + if s.override != nil { + return *s.override + } + return s.platform.IsDarkMode() +} + +func (s *Service) setThemeOverride(theme string) error { + switch theme { + case "", "system": + s.override = nil + case "dark": + value := true + s.override = &value + case "light": + value := false + s.override = &value + default: + return coreerr.E("environment.setThemeOverride", "theme must be one of: light, dark, system", nil) + } + + _ = s.Core().ACTION(ActionThemeChanged{IsDark: s.currentThemeIsDark()}) + return nil +} diff --git a/pkg/environment/service_test.go b/pkg/environment/service_test.go index d5dae911..8247344b 100644 --- a/pkg/environment/service_test.go +++ b/pkg/environment/service_test.go @@ -20,9 +20,9 @@ type mockPlatform struct { mu sync.Mutex } -func (m *mockPlatform) IsDarkMode() bool { return m.isDark } -func (m *mockPlatform) Info() EnvironmentInfo { return m.info } -func (m *mockPlatform) AccentColour() string { return m.accentColour } +func (m *mockPlatform) IsDarkMode() bool { return m.isDark } +func (m *mockPlatform) Info() EnvironmentInfo { return m.info } +func (m *mockPlatform) AccentColour() string { return m.accentColour } func (m *mockPlatform) OpenFileManager(path string, selectFile bool) error { return m.openFMErr } @@ -132,3 +132,37 @@ func TestThemeChange_ActionBroadcast_Good(t *testing.T) { require.NotNil(t, r) assert.False(t, r.IsDark) } + +func TestTaskSetTheme_Good(t *testing.T) { + _, c := newTestService(t) + + _, handled, err := c.PERFORM(TaskSetTheme{Theme: "light"}) + require.NoError(t, err) + assert.True(t, handled) + + result, handled, err := c.QUERY(QueryTheme{}) + require.NoError(t, err) + assert.True(t, handled) + theme := result.(ThemeInfo) + assert.False(t, theme.IsDark) + assert.Equal(t, "light", theme.Theme) +} + +func TestTaskSetTheme_Good_SystemClearsOverride(t *testing.T) { + mock, c := newTestService(t) + + _, _, err := c.PERFORM(TaskSetTheme{Theme: "light"}) + require.NoError(t, err) + + mock.isDark = true + _, handled, err := c.PERFORM(TaskSetTheme{Theme: "system"}) + require.NoError(t, err) + assert.True(t, handled) + + result, handled, err := c.QUERY(QueryTheme{}) + require.NoError(t, err) + assert.True(t, handled) + theme := result.(ThemeInfo) + assert.True(t, theme.IsDark) + assert.Equal(t, "dark", theme.Theme) +} diff --git a/pkg/mcp/layout_helpers.go b/pkg/mcp/layout_helpers.go new file mode 100644 index 00000000..b19fdb4c --- /dev/null +++ b/pkg/mcp/layout_helpers.go @@ -0,0 +1,195 @@ +package mcp + +import ( + "sort" + + "forge.lthn.ai/core/go/pkg/core" + "forge.lthn.ai/core/gui/pkg/screen" + "forge.lthn.ai/core/gui/pkg/window" +) + +func (s *Subsystem) allWindows() ([]window.WindowInfo, error) { + result, _, err := s.core.QUERY(window.QueryWindowList{}) + if err != nil { + return nil, err + } + windows, _ := result.([]window.WindowInfo) + return windows, nil +} + +func (s *Subsystem) allScreens() ([]screen.Screen, error) { + result, _, err := s.core.QUERY(screen.QueryAll{}) + if err != nil { + return nil, err + } + screens, _ := result.([]screen.Screen) + return screens, nil +} + +func (s *Subsystem) primaryScreen() (*screen.Screen, error) { + result, _, err := s.core.QUERY(screen.QueryPrimary{}) + if err != nil { + return nil, err + } + scr, _ := result.(*screen.Screen) + return scr, nil +} + +func (s *Subsystem) screenByID(id string) (*screen.Screen, error) { + if id == "" { + return nil, nil + } + result, _, err := s.core.QUERY(screen.QueryByID{ID: id}) + if err != nil { + return nil, err + } + scr, _ := result.(*screen.Screen) + return scr, nil +} + +func screenForWindowInfo(screens []screen.Screen, info window.WindowInfo) *screen.Screen { + cx := info.X + info.Width/2 + cy := info.Y + info.Height/2 + for i := range screens { + if screens[i].Bounds.Contains(cx, cy) { + return &screens[i] + } + } + return nil +} + +func chooseScreenByIDOrPrimary(screens []screen.Screen, screenID string) *screen.Screen { + if screenID != "" { + for i := range screens { + if screens[i].ID == screenID { + return &screens[i] + } + } + } + for i := range screens { + if screens[i].IsPrimary { + return &screens[i] + } + } + if len(screens) == 0 { + return nil + } + return &screens[0] +} + +func workAreaRect(scr *screen.Screen) screen.Rect { + if scr == nil { + return screen.Rect{} + } + if scr.WorkArea.Width > 0 && scr.WorkArea.Height > 0 { + return scr.WorkArea + } + return scr.Bounds +} + +func uniqueSorted(values []int) []int { + sort.Ints(values) + if len(values) == 0 { + return values + } + out := values[:1] + for _, value := range values[1:] { + if value != out[len(out)-1] { + out = append(out, value) + } + } + return out +} + +func clipRectToWorkArea(rect, workArea screen.Rect) (screen.Rect, bool) { + x1 := max(rect.X, workArea.X) + y1 := max(rect.Y, workArea.Y) + x2 := min(rect.X+rect.Width, workArea.X+workArea.Width) + y2 := min(rect.Y+rect.Height, workArea.Y+workArea.Height) + if x2 <= x1 || y2 <= y1 { + return screen.Rect{}, false + } + return screen.Rect{X: x1, Y: y1, Width: x2 - x1, Height: y2 - y1}, true +} + +func findLargestFreeRect(workArea screen.Rect, occupied []screen.Rect, minWidth, minHeight int) (screen.Rect, bool) { + xs := []int{workArea.X, workArea.X + workArea.Width} + ys := []int{workArea.Y, workArea.Y + workArea.Height} + + for _, rect := range occupied { + clipped, ok := clipRectToWorkArea(rect, workArea) + if !ok { + continue + } + xs = append(xs, clipped.X, clipped.X+clipped.Width) + ys = append(ys, clipped.Y, clipped.Y+clipped.Height) + } + + xs = uniqueSorted(xs) + ys = uniqueSorted(ys) + + bestArea := -1 + best := screen.Rect{} + + for xi := 0; xi < len(xs)-1; xi++ { + for xj := xi + 1; xj < len(xs); xj++ { + width := xs[xj] - xs[xi] + if width < minWidth { + continue + } + for yi := 0; yi < len(ys)-1; yi++ { + for yj := yi + 1; yj < len(ys); yj++ { + height := ys[yj] - ys[yi] + if height < minHeight { + continue + } + candidate := screen.Rect{X: xs[xi], Y: ys[yi], Width: width, Height: height} + if candidate.X < workArea.X || candidate.Y < workArea.Y || + candidate.X+candidate.Width > workArea.X+workArea.Width || + candidate.Y+candidate.Height > workArea.Y+workArea.Height { + continue + } + overlaps := false + for _, occ := range occupied { + if candidate.Overlaps(occ) { + overlaps = true + break + } + } + if overlaps { + continue + } + area := candidate.Width * candidate.Height + if area > bestArea || (area == bestArea && (candidate.Y < best.Y || (candidate.Y == best.Y && candidate.X < best.X))) { + bestArea = area + best = candidate + } + } + } + } + } + + return best, bestArea >= 0 +} + +func applyRect(c *core.Core, windowName string, rect screen.Rect) error { + if _, _, err := c.PERFORM(window.TaskSetPosition{Name: windowName, X: rect.X, Y: rect.Y}); err != nil { + return err + } + _, _, err := c.PERFORM(window.TaskSetSize{Name: windowName, Width: rect.Width, Height: rect.Height}) + return err +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/pkg/mcp/mcp_test.go b/pkg/mcp/mcp_test.go index 7b9d4296..15708d48 100644 --- a/pkg/mcp/mcp_test.go +++ b/pkg/mcp/mcp_test.go @@ -7,6 +7,9 @@ import ( "forge.lthn.ai/core/go/pkg/core" "forge.lthn.ai/core/gui/pkg/clipboard" + "forge.lthn.ai/core/gui/pkg/environment" + "forge.lthn.ai/core/gui/pkg/screen" + "forge.lthn.ai/core/gui/pkg/window" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -29,12 +32,20 @@ func TestSubsystem_Good_RegisterTools(t *testing.T) { // Integration test: verify the IPC round-trip that MCP tool handlers use. type mockClipPlatform struct { - text string - ok bool + text string + ok bool + image []byte + hasImage bool } func (m *mockClipPlatform) Text() (string, bool) { return m.text, m.ok } func (m *mockClipPlatform) SetText(t string) bool { m.text = t; m.ok = t != ""; return true } +func (m *mockClipPlatform) Image() ([]byte, bool) { return m.image, m.hasImage } +func (m *mockClipPlatform) SetImage(data []byte) bool { + m.image = append([]byte(nil), data...) + m.hasImage = len(data) > 0 + return true +} func TestMCP_Good_ClipboardRoundTrip(t *testing.T) { c, err := core.New( @@ -59,3 +70,87 @@ func TestMCP_Bad_NoServices(t *testing.T) { _, handled, _ := c.QUERY(clipboard.QueryText{}) assert.False(t, handled) } + +type mockEnvPlatform struct { + isDark bool +} + +func (m *mockEnvPlatform) IsDarkMode() bool { return m.isDark } +func (m *mockEnvPlatform) Info() environment.EnvironmentInfo { return environment.EnvironmentInfo{} } +func (m *mockEnvPlatform) AccentColour() string { return "" } +func (m *mockEnvPlatform) OpenFileManager(path string, selectFile bool) error { return nil } +func (m *mockEnvPlatform) HasFocusFollowsMouse() bool { return false } +func (m *mockEnvPlatform) OnThemeChange(handler func(isDark bool)) func() { + return func() {} +} + +type mockScreenPlatform struct { + screens []screen.Screen +} + +func (m *mockScreenPlatform) GetAll() []screen.Screen { return m.screens } +func (m *mockScreenPlatform) GetPrimary() *screen.Screen { + for i := range m.screens { + if m.screens[i].IsPrimary { + return &m.screens[i] + } + } + return nil +} +func (m *mockScreenPlatform) GetCurrent() *screen.Screen { return m.GetPrimary() } + +func TestMCP_Good_ThemeSetRoundTrip(t *testing.T) { + c, err := core.New( + core.WithService(environment.Register(&mockEnvPlatform{isDark: true})), + core.WithServiceLock(), + ) + require.NoError(t, err) + require.NoError(t, c.ServiceStartup(context.Background(), nil)) + + sub := NewSubsystem(c) + _, output, err := sub.themeSet(context.Background(), nil, ThemeSetInput{Theme: "light"}) + require.NoError(t, err) + assert.True(t, output.Success) + + result, handled, err := c.QUERY(environment.QueryTheme{}) + require.NoError(t, err) + assert.True(t, handled) + theme := result.(environment.ThemeInfo) + assert.Equal(t, "light", theme.Theme) + assert.False(t, theme.IsDark) +} + +func TestMCP_Good_ScreenFindSpaceAndArrangePair(t *testing.T) { + c, err := core.New( + core.WithService(screen.Register(&mockScreenPlatform{screens: []screen.Screen{ + { + ID: "1", Name: "Primary", IsPrimary: true, + Bounds: screen.Rect{X: 0, Y: 0, Width: 1600, Height: 900}, + WorkArea: screen.Rect{X: 0, Y: 0, Width: 1600, Height: 900}, + }, + }})), + core.WithService(window.Register(window.NewMockPlatform())), + core.WithServiceLock(), + ) + require.NoError(t, err) + require.NoError(t, c.ServiceStartup(context.Background(), nil)) + + _, _, err = c.PERFORM(window.TaskOpenWindow{Window: &window.Window{Name: "editor", X: 0, Y: 0, Width: 800, Height: 900}}) + require.NoError(t, err) + _, _, err = c.PERFORM(window.TaskOpenWindow{Window: &window.Window{Name: "preview", X: 800, Y: 0, Width: 800, Height: 450}}) + require.NoError(t, err) + + sub := NewSubsystem(c) + + _, free, err := sub.screenFindSpace(context.Background(), nil, ScreenFindSpaceInput{Width: 300, Height: 300}) + require.NoError(t, err) + assert.Equal(t, "1", free.ScreenID) + assert.Equal(t, screen.Rect{X: 800, Y: 450, Width: 800, Height: 450}, free.Bounds) + + _, arranged, err := sub.windowArrangePair(context.Background(), nil, WindowArrangePairInput{ + First: "editor", Second: "preview", + }) + require.NoError(t, err) + assert.Equal(t, screen.Rect{X: 0, Y: 0, Width: 800, Height: 900}, arranged.FirstBounds) + assert.Equal(t, screen.Rect{X: 800, Y: 0, Width: 800, Height: 900}, arranged.SecondBounds) +} diff --git a/pkg/mcp/tools_clipboard.go b/pkg/mcp/tools_clipboard.go index 82aa4358..0c7420dd 100644 --- a/pkg/mcp/tools_clipboard.go +++ b/pkg/mcp/tools_clipboard.go @@ -3,6 +3,7 @@ package mcp import ( "context" + "encoding/base64" coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/gui/pkg/clipboard" @@ -87,11 +88,60 @@ func (s *Subsystem) clipboardClear(_ context.Context, _ *mcp.CallToolRequest, _ return nil, ClipboardClearOutput{Success: success}, nil } +// --- clipboard_read_image --- + +type ClipboardReadImageInput struct{} +type ClipboardReadImageOutput struct { + Base64 string `json:"base64"` +} + +func (s *Subsystem) clipboardReadImage(_ context.Context, _ *mcp.CallToolRequest, _ ClipboardReadImageInput) (*mcp.CallToolResult, ClipboardReadImageOutput, error) { + result, _, err := s.core.QUERY(clipboard.QueryImage{}) + if err != nil { + return nil, ClipboardReadImageOutput{}, err + } + content, ok := result.(clipboard.ImageContent) + if !ok { + return nil, ClipboardReadImageOutput{}, coreerr.E("mcp.clipboardReadImage", "unexpected result type", nil) + } + if !content.HasImage { + return nil, ClipboardReadImageOutput{}, nil + } + return nil, ClipboardReadImageOutput{Base64: base64.StdEncoding.EncodeToString(content.Data)}, nil +} + +// --- clipboard_write_image --- + +type ClipboardWriteImageInput struct { + Base64 string `json:"base64"` +} +type ClipboardWriteImageOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) clipboardWriteImage(_ context.Context, _ *mcp.CallToolRequest, input ClipboardWriteImageInput) (*mcp.CallToolResult, ClipboardWriteImageOutput, error) { + data, err := base64.StdEncoding.DecodeString(input.Base64) + if err != nil { + return nil, ClipboardWriteImageOutput{}, err + } + result, _, err := s.core.PERFORM(clipboard.TaskSetImage{Data: data}) + if err != nil { + return nil, ClipboardWriteImageOutput{}, err + } + success, ok := result.(bool) + if !ok { + return nil, ClipboardWriteImageOutput{}, coreerr.E("mcp.clipboardWriteImage", "unexpected result type", nil) + } + return nil, ClipboardWriteImageOutput{Success: success}, nil +} + // --- Registration --- func (s *Subsystem) registerClipboardTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{Name: "clipboard_read", Description: "Read the current clipboard content"}, s.clipboardRead) mcp.AddTool(server, &mcp.Tool{Name: "clipboard_write", Description: "Write text to the clipboard"}, s.clipboardWrite) mcp.AddTool(server, &mcp.Tool{Name: "clipboard_has", Description: "Check if the clipboard has content"}, s.clipboardHas) + mcp.AddTool(server, &mcp.Tool{Name: "clipboard_read_image", Description: "Read image data from the clipboard as base64"}, s.clipboardReadImage) + mcp.AddTool(server, &mcp.Tool{Name: "clipboard_write_image", Description: "Write base64 image data to the clipboard"}, s.clipboardWriteImage) mcp.AddTool(server, &mcp.Tool{Name: "clipboard_clear", Description: "Clear the clipboard"}, s.clipboardClear) } diff --git a/pkg/mcp/tools_dialog.go b/pkg/mcp/tools_dialog.go index aee701d7..ff601940 100644 --- a/pkg/mcp/tools_dialog.go +++ b/pkg/mcp/tools_dialog.go @@ -92,6 +92,35 @@ func (s *Subsystem) dialogOpenDirectory(_ context.Context, _ *mcp.CallToolReques return nil, DialogOpenDirectoryOutput{Path: path}, nil } +// --- dialog_message --- + +type DialogMessageInput struct { + Type dialog.DialogType `json:"type"` + Title string `json:"title"` + Message string `json:"message"` + Buttons []string `json:"buttons,omitempty"` +} +type DialogMessageOutput struct { + Button string `json:"button"` +} + +func (s *Subsystem) dialogMessage(_ context.Context, _ *mcp.CallToolRequest, input DialogMessageInput) (*mcp.CallToolResult, DialogMessageOutput, error) { + result, _, err := s.core.PERFORM(dialog.TaskMessageDialog{Options: dialog.MessageDialogOptions{ + Type: input.Type, + Title: input.Title, + Message: input.Message, + Buttons: input.Buttons, + }}) + if err != nil { + return nil, DialogMessageOutput{}, err + } + button, ok := result.(string) + if !ok { + return nil, DialogMessageOutput{}, coreerr.E("mcp.dialogMessage", "unexpected result type", nil) + } + return nil, DialogMessageOutput{Button: button}, nil +} + // --- dialog_confirm --- type DialogConfirmInput struct { @@ -153,6 +182,7 @@ func (s *Subsystem) registerDialogTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{Name: "dialog_open_file", Description: "Show an open file dialog"}, s.dialogOpenFile) mcp.AddTool(server, &mcp.Tool{Name: "dialog_save_file", Description: "Show a save file dialog"}, s.dialogSaveFile) mcp.AddTool(server, &mcp.Tool{Name: "dialog_open_directory", Description: "Show a directory picker dialog"}, s.dialogOpenDirectory) + mcp.AddTool(server, &mcp.Tool{Name: "dialog_message", Description: "Show a message dialog"}, s.dialogMessage) mcp.AddTool(server, &mcp.Tool{Name: "dialog_confirm", Description: "Show a confirmation dialog"}, s.dialogConfirm) mcp.AddTool(server, &mcp.Tool{Name: "dialog_prompt", Description: "Show a prompt dialog"}, s.dialogPrompt) } diff --git a/pkg/mcp/tools_environment.go b/pkg/mcp/tools_environment.go index c8fc8310..dded41c8 100644 --- a/pkg/mcp/tools_environment.go +++ b/pkg/mcp/tools_environment.go @@ -47,9 +47,27 @@ func (s *Subsystem) themeSystem(_ context.Context, _ *mcp.CallToolRequest, _ The return nil, ThemeSystemOutput{Info: info}, nil } +// --- theme_set --- + +type ThemeSetInput struct { + Theme string `json:"theme"` +} +type ThemeSetOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) themeSet(_ context.Context, _ *mcp.CallToolRequest, input ThemeSetInput) (*mcp.CallToolResult, ThemeSetOutput, error) { + _, _, err := s.core.PERFORM(environment.TaskSetTheme{Theme: input.Theme}) + if err != nil { + return nil, ThemeSetOutput{}, err + } + return nil, ThemeSetOutput{Success: true}, nil +} + // --- Registration --- func (s *Subsystem) registerEnvironmentTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{Name: "theme_get", Description: "Get the current application theme"}, s.themeGet) + mcp.AddTool(server, &mcp.Tool{Name: "theme_set", Description: "Override the application theme to light, dark, or system"}, s.themeSet) mcp.AddTool(server, &mcp.Tool{Name: "theme_system", Description: "Get system environment and theme information"}, s.themeSystem) } diff --git a/pkg/mcp/tools_layout.go b/pkg/mcp/tools_layout.go index 1719ce5d..b57f8edc 100644 --- a/pkg/mcp/tools_layout.go +++ b/pkg/mcp/tools_layout.go @@ -3,8 +3,10 @@ package mcp import ( "context" + "strings" coreerr "forge.lthn.ai/core/go-log" + "forge.lthn.ai/core/gui/pkg/screen" "forge.lthn.ai/core/gui/pkg/window" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -173,6 +175,179 @@ func (s *Subsystem) layoutWorkflow(_ context.Context, _ *mcp.CallToolRequest, in return nil, LayoutWorkflowOutput{Success: true}, nil } +// --- layout_suggest --- + +type LayoutSuggestInput struct { + Width int `json:"width"` + Height int `json:"height"` + WindowCount int `json:"windowCount"` +} +type LayoutSuggestOutput struct { + Mode string `json:"mode"` + Placements []screen.Rect `json:"placements"` +} + +func (s *Subsystem) layoutSuggest(_ context.Context, _ *mcp.CallToolRequest, input LayoutSuggestInput) (*mcp.CallToolResult, LayoutSuggestOutput, error) { + width := input.Width + height := input.Height + if width <= 0 { + width = 1920 + } + if height <= 0 { + height = 1080 + } + count := input.WindowCount + if count <= 0 { + count = 1 + } + + workArea := screen.Rect{X: 0, Y: 0, Width: width, Height: height} + switch { + case count == 1: + return nil, LayoutSuggestOutput{Mode: "full", Placements: []screen.Rect{workArea}}, nil + case count == 2: + if width >= height { + half := width / 2 + return nil, LayoutSuggestOutput{ + Mode: "side-by-side", + Placements: []screen.Rect{ + {X: 0, Y: 0, Width: half, Height: height}, + {X: half, Y: 0, Width: width - half, Height: height}, + }, + }, nil + } + half := height / 2 + return nil, LayoutSuggestOutput{ + Mode: "stacked", + Placements: []screen.Rect{ + {X: 0, Y: 0, Width: width, Height: half}, + {X: 0, Y: half, Width: width, Height: height - half}, + }, + }, nil + case count == 3 && width >= height: + mainWidth := width * 2 / 3 + sideHeight := height / 2 + return nil, LayoutSuggestOutput{ + Mode: "editor-plus-stack", + Placements: []screen.Rect{ + {X: 0, Y: 0, Width: mainWidth, Height: height}, + {X: mainWidth, Y: 0, Width: width - mainWidth, Height: sideHeight}, + {X: mainWidth, Y: sideHeight, Width: width - mainWidth, Height: height - sideHeight}, + }, + }, nil + default: + cols := 2 + if count > 4 { + cols = 3 + } + rows := (count + cols - 1) / cols + cellWidth := width / cols + cellHeight := height / rows + placements := make([]screen.Rect, 0, count) + for i := 0; i < count; i++ { + row := i / cols + col := i % cols + placements = append(placements, screen.Rect{ + X: col * cellWidth, Y: row * cellHeight, + Width: cellWidth, Height: cellHeight, + }) + } + return nil, LayoutSuggestOutput{Mode: "grid", Placements: placements}, nil + } +} + +// --- layout_beside_editor --- + +type LayoutBesideEditorInput struct { + Name string `json:"name"` + EditorNames []string `json:"editorNames,omitempty"` +} +type LayoutBesideEditorOutput struct { + Editor string `json:"editor"` + Bounds screen.Rect `json:"bounds"` +} + +func (s *Subsystem) layoutBesideEditor(_ context.Context, _ *mcp.CallToolRequest, input LayoutBesideEditorInput) (*mcp.CallToolResult, LayoutBesideEditorOutput, error) { + windows, err := s.allWindows() + if err != nil { + return nil, LayoutBesideEditorOutput{}, err + } + screens, err := s.allScreens() + if err != nil { + return nil, LayoutBesideEditorOutput{}, err + } + + editorHints := map[string]struct{}{} + for _, name := range input.EditorNames { + editorHints[strings.ToLower(name)] = struct{}{} + } + defaultHints := []string{"code", "cursor", "vscode", "studio", "goland", "intellij", "webstorm", "xcode", "vim", "nvim", "emacs", "editor"} + + var editor *window.WindowInfo + for i := range windows { + if windows[i].Name == input.Name { + continue + } + name := strings.ToLower(windows[i].Name) + title := strings.ToLower(windows[i].Title) + if _, ok := editorHints[name]; ok { + editor = &windows[i] + break + } + for _, hint := range defaultHints { + if strings.Contains(name, hint) || strings.Contains(title, hint) { + editor = &windows[i] + break + } + } + if editor != nil { + break + } + } + if editor == nil { + return nil, LayoutBesideEditorOutput{}, coreerr.E("mcp.layoutBesideEditor", "no editor window detected", nil) + } + + editorScreen := screenForWindowInfo(screens, *editor) + if editorScreen == nil { + editorScreen = chooseScreenByIDOrPrimary(screens, "") + } + workArea := workAreaRect(editorScreen) + + editorRect := screen.Rect{X: editor.X, Y: editor.Y, Width: editor.Width, Height: editor.Height} + candidates := []screen.Rect{ + {X: workArea.X, Y: workArea.Y, Width: max(0, editorRect.X-workArea.X), Height: workArea.Height}, + {X: editorRect.X + editorRect.Width, Y: workArea.Y, Width: max(0, workArea.X+workArea.Width-(editorRect.X+editorRect.Width)), Height: workArea.Height}, + {X: workArea.X, Y: workArea.Y, Width: workArea.Width, Height: max(0, editorRect.Y-workArea.Y)}, + {X: workArea.X, Y: editorRect.Y + editorRect.Height, Width: workArea.Width, Height: max(0, workArea.Y+workArea.Height-(editorRect.Y+editorRect.Height))}, + } + + best := screen.Rect{} + bestArea := -1 + for _, candidate := range candidates { + area := candidate.Width * candidate.Height + if candidate.Width <= 0 || candidate.Height <= 0 { + continue + } + if area > bestArea { + bestArea = area + best = candidate + } + } + if bestArea <= 0 { + arranged, err := s.arrangePairOnScreen(editor.Name, input.Name, editorScreen, "") + if err != nil { + return nil, LayoutBesideEditorOutput{}, err + } + return nil, LayoutBesideEditorOutput{Editor: editor.Name, Bounds: arranged.Second}, nil + } + + if err := applyRect(s.core, input.Name, best); err != nil { + return nil, LayoutBesideEditorOutput{}, err + } + return nil, LayoutBesideEditorOutput{Editor: editor.Name, Bounds: best}, nil +} + // --- Registration --- func (s *Subsystem) registerLayoutTools(server *mcp.Server) { @@ -182,6 +357,8 @@ func (s *Subsystem) registerLayoutTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{Name: "layout_delete", Description: "Delete a saved layout"}, s.layoutDelete) mcp.AddTool(server, &mcp.Tool{Name: "layout_get", Description: "Get a specific layout by name"}, s.layoutGet) mcp.AddTool(server, &mcp.Tool{Name: "layout_tile", Description: "Tile windows in a grid arrangement"}, s.layoutTile) + mcp.AddTool(server, &mcp.Tool{Name: "layout_suggest", Description: "Suggest an optimal arrangement for the given screen size and window count"}, s.layoutSuggest) + mcp.AddTool(server, &mcp.Tool{Name: "layout_beside_editor", Description: "Place a window beside a detected editor or IDE window"}, s.layoutBesideEditor) mcp.AddTool(server, &mcp.Tool{Name: "layout_snap", Description: "Snap a window to a screen edge or corner"}, s.layoutSnap) mcp.AddTool(server, &mcp.Tool{Name: "layout_stack", Description: "Stack windows in a cascade pattern"}, s.layoutStack) mcp.AddTool(server, &mcp.Tool{Name: "layout_workflow", Description: "Apply a preset workflow layout"}, s.layoutWorkflow) diff --git a/pkg/mcp/tools_notification.go b/pkg/mcp/tools_notification.go index 0b965d38..1eeb9e08 100644 --- a/pkg/mcp/tools_notification.go +++ b/pkg/mcp/tools_notification.go @@ -32,6 +32,31 @@ func (s *Subsystem) notificationShow(_ context.Context, _ *mcp.CallToolRequest, return nil, NotificationShowOutput{Success: true}, nil } +// --- notification_with_actions --- + +type NotificationWithActionsInput struct { + Title string `json:"title"` + Message string `json:"message"` + Subtitle string `json:"subtitle,omitempty"` + Actions []notification.NotificationAction `json:"actions"` +} +type NotificationWithActionsOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) notificationWithActions(_ context.Context, _ *mcp.CallToolRequest, input NotificationWithActionsInput) (*mcp.CallToolResult, NotificationWithActionsOutput, error) { + _, _, err := s.core.PERFORM(notification.TaskSend{Options: notification.NotificationOptions{ + Title: input.Title, + Message: input.Message, + Subtitle: input.Subtitle, + Actions: input.Actions, + }}) + if err != nil { + return nil, NotificationWithActionsOutput{}, err + } + return nil, NotificationWithActionsOutput{Success: true}, nil +} + // --- notification_permission_request --- type NotificationPermissionRequestInput struct{} @@ -70,10 +95,29 @@ func (s *Subsystem) notificationPermissionCheck(_ context.Context, _ *mcp.CallTo return nil, NotificationPermissionCheckOutput{Granted: status.Granted}, nil } +// --- notification_clear --- + +type NotificationClearInput struct { + ID string `json:"id,omitempty"` +} +type NotificationClearOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) notificationClear(_ context.Context, _ *mcp.CallToolRequest, input NotificationClearInput) (*mcp.CallToolResult, NotificationClearOutput, error) { + _, _, err := s.core.PERFORM(notification.TaskClear{ID: input.ID}) + if err != nil { + return nil, NotificationClearOutput{}, err + } + return nil, NotificationClearOutput{Success: true}, nil +} + // --- Registration --- func (s *Subsystem) registerNotificationTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{Name: "notification_show", Description: "Show a desktop notification"}, s.notificationShow) + mcp.AddTool(server, &mcp.Tool{Name: "notification_with_actions", Description: "Show a desktop notification with action buttons"}, s.notificationWithActions) mcp.AddTool(server, &mcp.Tool{Name: "notification_permission_request", Description: "Request notification permission"}, s.notificationPermissionRequest) mcp.AddTool(server, &mcp.Tool{Name: "notification_permission_check", Description: "Check notification permission status"}, s.notificationPermissionCheck) + mcp.AddTool(server, &mcp.Tool{Name: "notification_clear", Description: "Clear a notification by ID or clear all notifications"}, s.notificationClear) } diff --git a/pkg/mcp/tools_screen.go b/pkg/mcp/tools_screen.go index 7f86e7e9..90633147 100644 --- a/pkg/mcp/tools_screen.go +++ b/pkg/mcp/tools_screen.go @@ -110,6 +110,75 @@ func (s *Subsystem) screenWorkAreas(_ context.Context, _ *mcp.CallToolRequest, _ return nil, ScreenWorkAreasOutput{WorkAreas: areas}, nil } +// --- screen_work_area --- + +type ScreenWorkAreaInput struct { + ID string `json:"id,omitempty"` +} +type ScreenWorkAreaOutput struct { + WorkArea screen.Rect `json:"workArea"` +} + +func (s *Subsystem) screenWorkArea(_ context.Context, _ *mcp.CallToolRequest, input ScreenWorkAreaInput) (*mcp.CallToolResult, ScreenWorkAreaOutput, error) { + screens, err := s.allScreens() + if err != nil { + return nil, ScreenWorkAreaOutput{}, err + } + scr := chooseScreenByIDOrPrimary(screens, input.ID) + if scr == nil { + return nil, ScreenWorkAreaOutput{}, nil + } + return nil, ScreenWorkAreaOutput{WorkArea: workAreaRect(scr)}, nil +} + +// --- screen_find_space --- + +type ScreenFindSpaceInput struct { + ScreenID string `json:"screenId,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` +} +type ScreenFindSpaceOutput struct { + ScreenID string `json:"screenId"` + Bounds screen.Rect `json:"bounds"` +} + +func (s *Subsystem) screenFindSpace(_ context.Context, _ *mcp.CallToolRequest, input ScreenFindSpaceInput) (*mcp.CallToolResult, ScreenFindSpaceOutput, error) { + screens, err := s.allScreens() + if err != nil { + return nil, ScreenFindSpaceOutput{}, err + } + windows, err := s.allWindows() + if err != nil { + return nil, ScreenFindSpaceOutput{}, err + } + + orderedScreens := make([]screen.Screen, 0, len(screens)) + if selected := chooseScreenByIDOrPrimary(screens, input.ScreenID); selected != nil { + orderedScreens = append(orderedScreens, *selected) + for _, scr := range screens { + if scr.ID != selected.ID { + orderedScreens = append(orderedScreens, scr) + } + } + } + + for _, scr := range orderedScreens { + workArea := workAreaRect(&scr) + occupied := make([]screen.Rect, 0, len(windows)) + for _, info := range windows { + if windowScreen := screenForWindowInfo(screens, info); windowScreen != nil && windowScreen.ID == scr.ID { + occupied = append(occupied, screen.Rect{X: info.X, Y: info.Y, Width: info.Width, Height: info.Height}) + } + } + if candidate, ok := findLargestFreeRect(workArea, occupied, input.Width, input.Height); ok { + return nil, ScreenFindSpaceOutput{ScreenID: scr.ID, Bounds: candidate}, nil + } + } + + return nil, ScreenFindSpaceOutput{}, nil +} + // --- screen_for_window --- type ScreenForWindowInput struct { @@ -145,6 +214,8 @@ func (s *Subsystem) registerScreenTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{Name: "screen_get", Description: "Get information about a specific screen"}, s.screenGet) mcp.AddTool(server, &mcp.Tool{Name: "screen_primary", Description: "Get the primary screen"}, s.screenPrimary) mcp.AddTool(server, &mcp.Tool{Name: "screen_at_point", Description: "Get the screen at a specific point"}, s.screenAtPoint) + mcp.AddTool(server, &mcp.Tool{Name: "screen_work_area", Description: "Get the work area for a screen"}, s.screenWorkArea) mcp.AddTool(server, &mcp.Tool{Name: "screen_work_areas", Description: "Get work areas for all screens"}, s.screenWorkAreas) + mcp.AddTool(server, &mcp.Tool{Name: "screen_find_space", Description: "Find the largest empty area on a screen that fits the requested size"}, s.screenFindSpace) mcp.AddTool(server, &mcp.Tool{Name: "screen_for_window", Description: "Get the screen containing a window"}, s.screenForWindow) } diff --git a/pkg/mcp/tools_tray.go b/pkg/mcp/tools_tray.go index d5efb457..ee2cfb1d 100644 --- a/pkg/mcp/tools_tray.go +++ b/pkg/mcp/tools_tray.go @@ -4,7 +4,6 @@ package mcp import ( "context" - coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/gui/pkg/systray" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -36,8 +35,10 @@ type TraySetTooltipOutput struct { } func (s *Subsystem) traySetTooltip(_ context.Context, _ *mcp.CallToolRequest, input TraySetTooltipInput) (*mcp.CallToolResult, TraySetTooltipOutput, error) { - // Tooltip is set via the tray menu items; for now this is a no-op placeholder - _ = input.Tooltip + _, _, err := s.core.PERFORM(systray.TaskSetTrayTooltip{Tooltip: input.Tooltip}) + if err != nil { + return nil, TraySetTooltipOutput{}, err + } return nil, TraySetTooltipOutput{Success: true}, nil } @@ -51,11 +52,52 @@ type TraySetLabelOutput struct { } func (s *Subsystem) traySetLabel(_ context.Context, _ *mcp.CallToolRequest, input TraySetLabelInput) (*mcp.CallToolResult, TraySetLabelOutput, error) { - // Label is part of the tray configuration; placeholder for now - _ = input.Label + _, _, err := s.core.PERFORM(systray.TaskSetTrayLabel{Label: input.Label}) + if err != nil { + return nil, TraySetLabelOutput{}, err + } return nil, TraySetLabelOutput{Success: true}, nil } +// --- tray_set_menu --- + +type TraySetMenuInput struct { + Items []map[string]any `json:"items"` +} +type TraySetMenuOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) traySetMenu(_ context.Context, _ *mcp.CallToolRequest, input TraySetMenuInput) (*mcp.CallToolResult, TraySetMenuOutput, error) { + items := make([]systray.TrayMenuItem, 0, len(input.Items)) + for _, item := range input.Items { + items = append(items, decodeTrayMenuItem(item)) + } + _, _, err := s.core.PERFORM(systray.TaskSetTrayMenu{Items: items}) + if err != nil { + return nil, TraySetMenuOutput{}, err + } + return nil, TraySetMenuOutput{Success: true}, nil +} + +// --- tray_show_message --- + +type TrayShowMessageInput struct { + Title string `json:"title"` + Message string `json:"message"` +} +type TrayShowMessageOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) trayShowMessage(_ context.Context, _ *mcp.CallToolRequest, input TrayShowMessageInput) (*mcp.CallToolResult, TrayShowMessageOutput, error) { + _, _, err := s.core.PERFORM(systray.TaskShowMessage{Title: input.Title, Message: input.Message}) + if err != nil { + return nil, TrayShowMessageOutput{}, err + } + return nil, TrayShowMessageOutput{Success: true}, nil +} + // --- tray_info --- type TrayInfoInput struct{} @@ -70,7 +112,7 @@ func (s *Subsystem) trayInfo(_ context.Context, _ *mcp.CallToolRequest, _ TrayIn } config, ok := result.(map[string]any) if !ok { - return nil, TrayInfoOutput{}, coreerr.E("mcp.trayInfo", "unexpected result type", nil) + config = map[string]any{} } return nil, TrayInfoOutput{Config: config}, nil } @@ -81,5 +123,41 @@ func (s *Subsystem) registerTrayTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{Name: "tray_set_icon", Description: "Set the system tray icon"}, s.traySetIcon) mcp.AddTool(server, &mcp.Tool{Name: "tray_set_tooltip", Description: "Set the system tray tooltip"}, s.traySetTooltip) mcp.AddTool(server, &mcp.Tool{Name: "tray_set_label", Description: "Set the system tray label"}, s.traySetLabel) + mcp.AddTool(server, &mcp.Tool{Name: "tray_set_menu", Description: "Set the system tray menu"}, s.traySetMenu) + mcp.AddTool(server, &mcp.Tool{Name: "tray_show_message", Description: "Show a tray balloon or tray message"}, s.trayShowMessage) mcp.AddTool(server, &mcp.Tool{Name: "tray_info", Description: "Get system tray configuration"}, s.trayInfo) } + +func decodeTrayMenuItem(input map[string]any) systray.TrayMenuItem { + item := systray.TrayMenuItem{} + if label, ok := input["label"].(string); ok { + item.Label = label + } + if itemType, ok := input["type"].(string); ok { + item.Type = itemType + } + if checked, ok := input["checked"].(bool); ok { + item.Checked = checked + } + if disabled, ok := input["disabled"].(bool); ok { + item.Disabled = disabled + } + if tooltip, ok := input["tooltip"].(string); ok { + item.Tooltip = tooltip + } + if actionID, ok := input["actionId"].(string); ok { + item.ActionID = actionID + } + if actionID, ok := input["action_id"].(string); ok && item.ActionID == "" { + item.ActionID = actionID + } + if rawSubmenu, ok := input["submenu"].([]any); ok { + item.Submenu = make([]systray.TrayMenuItem, 0, len(rawSubmenu)) + for _, child := range rawSubmenu { + if childMap, ok := child.(map[string]any); ok { + item.Submenu = append(item.Submenu, decodeTrayMenuItem(childMap)) + } + } + } + return item +} diff --git a/pkg/mcp/tools_window.go b/pkg/mcp/tools_window.go index 677c2acf..131e16de 100644 --- a/pkg/mcp/tools_window.go +++ b/pkg/mcp/tools_window.go @@ -5,6 +5,7 @@ import ( "context" coreerr "forge.lthn.ai/core/go-log" + "forge.lthn.ai/core/gui/pkg/screen" "forge.lthn.ai/core/gui/pkg/window" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -258,6 +259,23 @@ func (s *Subsystem) windowFocus(_ context.Context, _ *mcp.CallToolRequest, input return nil, WindowFocusOutput{Success: true}, nil } +// --- focus_set --- + +type FocusSetInput struct { + Name string `json:"name"` +} +type FocusSetOutput struct { + Success bool `json:"success"` +} + +func (s *Subsystem) focusSet(ctx context.Context, req *mcp.CallToolRequest, input FocusSetInput) (*mcp.CallToolResult, FocusSetOutput, error) { + _, out, err := s.windowFocus(ctx, req, WindowFocusInput{Name: input.Name}) + if err != nil { + return nil, FocusSetOutput{}, err + } + return nil, FocusSetOutput{Success: out.Success}, nil +} + // --- window_title --- type WindowTitleInput struct { @@ -374,6 +392,94 @@ func (s *Subsystem) windowFullscreen(_ context.Context, _ *mcp.CallToolRequest, return nil, WindowFullscreenOutput{Success: true}, nil } +type arrangedPair struct { + First screen.Rect + Second screen.Rect +} + +func (s *Subsystem) arrangePairOnScreen(firstName, secondName string, scr *screen.Screen, orientation string) (arrangedPair, error) { + workArea := workAreaRect(scr) + if workArea.Width == 0 || workArea.Height == 0 { + return arrangedPair{}, coreerr.E("mcp.arrangePairOnScreen", "screen work area is empty", nil) + } + if orientation == "" { + if workArea.Width >= workArea.Height { + orientation = "horizontal" + } else { + orientation = "vertical" + } + } + + var firstRect screen.Rect + var secondRect screen.Rect + switch orientation { + case "vertical", "stacked": + firstHeight := workArea.Height / 2 + firstRect = screen.Rect{X: workArea.X, Y: workArea.Y, Width: workArea.Width, Height: firstHeight} + secondRect = screen.Rect{X: workArea.X, Y: workArea.Y + firstHeight, Width: workArea.Width, Height: workArea.Height - firstHeight} + default: + firstWidth := workArea.Width / 2 + firstRect = screen.Rect{X: workArea.X, Y: workArea.Y, Width: firstWidth, Height: workArea.Height} + secondRect = screen.Rect{X: workArea.X + firstWidth, Y: workArea.Y, Width: workArea.Width - firstWidth, Height: workArea.Height} + } + + if err := applyRect(s.core, firstName, firstRect); err != nil { + return arrangedPair{}, err + } + if err := applyRect(s.core, secondName, secondRect); err != nil { + return arrangedPair{}, err + } + return arrangedPair{First: firstRect, Second: secondRect}, nil +} + +// --- window_arrange_pair --- + +type WindowArrangePairInput struct { + First string `json:"first"` + Second string `json:"second"` + ScreenID string `json:"screenId,omitempty"` + Orientation string `json:"orientation,omitempty"` +} +type WindowArrangePairOutput struct { + FirstBounds screen.Rect `json:"firstBounds"` + SecondBounds screen.Rect `json:"secondBounds"` +} + +func (s *Subsystem) windowArrangePair(_ context.Context, _ *mcp.CallToolRequest, input WindowArrangePairInput) (*mcp.CallToolResult, WindowArrangePairOutput, error) { + screens, err := s.allScreens() + if err != nil { + return nil, WindowArrangePairOutput{}, err + } + windows, err := s.allWindows() + if err != nil { + return nil, WindowArrangePairOutput{}, err + } + + var targetScreen *screen.Screen + if input.ScreenID != "" { + targetScreen = chooseScreenByIDOrPrimary(screens, input.ScreenID) + } else { + for _, info := range windows { + if info.Name == input.First { + targetScreen = screenForWindowInfo(screens, info) + break + } + } + if targetScreen == nil { + targetScreen = chooseScreenByIDOrPrimary(screens, "") + } + } + if targetScreen == nil { + return nil, WindowArrangePairOutput{}, coreerr.E("mcp.windowArrangePair", "no screen available", nil) + } + + arranged, err := s.arrangePairOnScreen(input.First, input.Second, targetScreen, input.Orientation) + if err != nil { + return nil, WindowArrangePairOutput{}, err + } + return nil, WindowArrangePairOutput{FirstBounds: arranged.First, SecondBounds: arranged.Second}, nil +} + // --- Registration --- func (s *Subsystem) registerWindowTools(server *mcp.Server) { @@ -389,10 +495,12 @@ func (s *Subsystem) registerWindowTools(server *mcp.Server) { mcp.AddTool(server, &mcp.Tool{Name: "window_minimize", Description: "Minimise a window"}, s.windowMinimize) mcp.AddTool(server, &mcp.Tool{Name: "window_restore", Description: "Restore a maximised or minimised window"}, s.windowRestore) mcp.AddTool(server, &mcp.Tool{Name: "window_focus", Description: "Bring a window to the front"}, s.windowFocus) + mcp.AddTool(server, &mcp.Tool{Name: "focus_set", Description: "Set focus to a specific window"}, s.focusSet) mcp.AddTool(server, &mcp.Tool{Name: "window_title", Description: "Set the title of a window"}, s.windowTitle) mcp.AddTool(server, &mcp.Tool{Name: "window_title_get", Description: "Get the title of a window"}, s.windowTitleGet) mcp.AddTool(server, &mcp.Tool{Name: "window_visibility", Description: "Show or hide a window"}, s.windowVisibility) mcp.AddTool(server, &mcp.Tool{Name: "window_always_on_top", Description: "Pin a window above others"}, s.windowAlwaysOnTop) mcp.AddTool(server, &mcp.Tool{Name: "window_background_colour", Description: "Set a window background colour"}, s.windowBackgroundColour) mcp.AddTool(server, &mcp.Tool{Name: "window_fullscreen", Description: "Set a window to fullscreen mode"}, s.windowFullscreen) + mcp.AddTool(server, &mcp.Tool{Name: "window_arrange_pair", Description: "Arrange two windows side-by-side or stacked on a screen"}, s.windowArrangePair) } diff --git a/pkg/notification/messages.go b/pkg/notification/messages.go index 784dfd5c..df7b60c6 100644 --- a/pkg/notification/messages.go +++ b/pkg/notification/messages.go @@ -19,6 +19,9 @@ type TaskRevokePermission struct{} // _, _, err := c.PERFORM(TaskRegisterCategory{Category: NotificationCategory{ID: "message", Actions: [...]}}) type TaskRegisterCategory struct{ Category NotificationCategory } +// TaskClear removes a notification by ID. An empty ID clears all notifications if supported. +type TaskClear struct{ ID string } + // ActionNotificationClicked is broadcast when the user clicks a notification. type ActionNotificationClicked struct{ ID string } diff --git a/pkg/notification/platform.go b/pkg/notification/platform.go index 57639ce8..c363990f 100644 --- a/pkg/notification/platform.go +++ b/pkg/notification/platform.go @@ -10,6 +10,11 @@ type Platform interface { RegisterCategory(category NotificationCategory) error } +// ClearPlatform is an optional extension for removing notifications. +type ClearPlatform interface { + Clear(id string) error +} + // NotificationSeverity indicates the severity for dialog fallback. type NotificationSeverity int @@ -21,11 +26,13 @@ const ( // NotificationOptions contains options for sending a notification. type NotificationOptions struct { - ID string `json:"id,omitempty"` - Title string `json:"title"` - Message string `json:"message"` - Subtitle string `json:"subtitle,omitempty"` - Severity NotificationSeverity `json:"severity,omitempty"` + ID string `json:"id,omitempty"` + Title string `json:"title"` + Message string `json:"message"` + Subtitle string `json:"subtitle,omitempty"` + Severity NotificationSeverity `json:"severity,omitempty"` + CategoryID string `json:"categoryId,omitempty"` + Actions []NotificationAction `json:"actions,omitempty"` } // PermissionStatus indicates whether notifications are authorised. diff --git a/pkg/notification/service.go b/pkg/notification/service.go index 866a87f7..57a7b0ab 100644 --- a/pkg/notification/service.go +++ b/pkg/notification/service.go @@ -6,6 +6,7 @@ import ( "strconv" "time" + coreerr "forge.lthn.ai/core/go-log" "forge.lthn.ai/core/go/pkg/core" "forge.lthn.ai/core/gui/pkg/dialog" ) @@ -57,6 +58,12 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { return nil, true, s.platform.RevokePermission() case TaskRegisterCategory: return nil, true, s.platform.RegisterCategory(t.Category) + case TaskClear: + clearPlatform, ok := s.platform.(ClearPlatform) + if !ok { + return nil, true, coreerr.E("notification.handleTask", "notification clearing is not supported by this platform", nil) + } + return nil, true, clearPlatform.Clear(t.ID) default: return nil, false, nil } @@ -69,6 +76,20 @@ func (s *Service) send(options NotificationOptions) error { options.ID = "core-" + strconv.FormatInt(time.Now().UnixNano(), 10) } + if len(options.Actions) > 0 { + categoryID := options.CategoryID + if categoryID == "" { + categoryID = options.ID + } + if err := s.platform.RegisterCategory(NotificationCategory{ + ID: categoryID, + Actions: options.Actions, + }); err != nil { + return err + } + options.CategoryID = categoryID + } + if err := s.platform.Send(options); err != nil { // Fallback: show as dialog via IPC return s.fallbackDialog(options) diff --git a/pkg/notification/service_test.go b/pkg/notification/service_test.go index d6ddb515..3a74f140 100644 --- a/pkg/notification/service_test.go +++ b/pkg/notification/service_test.go @@ -13,16 +13,19 @@ import ( ) type mockPlatform struct { - sendErr error - permGranted bool - permErr error - revokeErr error - registerCategoryErr error - lastOpts NotificationOptions - lastCategory NotificationCategory - sendCalled bool - revokeCalled bool + sendErr error + permGranted bool + permErr error + revokeErr error + registerCategoryErr error + clearErr error + lastOpts NotificationOptions + lastCategory NotificationCategory + sendCalled bool + revokeCalled bool registerCategoryCalled bool + clearCalled bool + lastClearedID string } func (m *mockPlatform) Send(opts NotificationOptions) error { @@ -41,6 +44,11 @@ func (m *mockPlatform) RegisterCategory(category NotificationCategory) error { m.lastCategory = category return m.registerCategoryErr } +func (m *mockPlatform) Clear(id string) error { + m.clearCalled = true + m.lastClearedID = id + return m.clearErr +} // mockDialogPlatform tracks whether MessageDialog was called (for fallback test). type mockDialogPlatform struct { @@ -171,6 +179,36 @@ func TestTaskRegisterCategory_Bad_NoService(t *testing.T) { assert.False(t, handled) } +func TestTaskSendWithActions_Good(t *testing.T) { + mock, c := newTestService(t) + + _, handled, err := c.PERFORM(TaskSend{ + Options: NotificationOptions{ + Title: "Message", + Message: "Reply?", + Actions: []NotificationAction{ + {ID: "reply", Title: "Reply"}, + {ID: "dismiss", Title: "Dismiss"}, + }, + }, + }) + require.NoError(t, err) + assert.True(t, handled) + assert.True(t, mock.registerCategoryCalled) + assert.Len(t, mock.lastCategory.Actions, 2) + assert.NotEmpty(t, mock.lastOpts.CategoryID) +} + +func TestTaskClear_Good(t *testing.T) { + mock, c := newTestService(t) + + _, handled, err := c.PERFORM(TaskClear{ID: "notif-1"}) + require.NoError(t, err) + assert.True(t, handled) + assert.True(t, mock.clearCalled) + assert.Equal(t, "notif-1", mock.lastClearedID) +} + func TestActionNotificationActionTriggered_Ugly(t *testing.T) { // Verify the action structs are distinct types. var triggered ActionNotificationActionTriggered diff --git a/pkg/systray/messages.go b/pkg/systray/messages.go index 6855e229..273d4d43 100644 --- a/pkg/systray/messages.go +++ b/pkg/systray/messages.go @@ -4,8 +4,17 @@ type QueryConfig struct{} type TaskSetTrayIcon struct{ Data []byte } +type TaskSetTrayTooltip struct{ Tooltip string } + +type TaskSetTrayLabel struct{ Label string } + type TaskSetTrayMenu struct{ Items []TrayMenuItem } +type TaskShowMessage struct { + Title string `json:"title"` + Message string `json:"message"` +} + type TaskShowPanel struct{} type TaskHidePanel struct{} diff --git a/pkg/systray/mock_platform.go b/pkg/systray/mock_platform.go index c92a58fb..1c86d9de 100644 --- a/pkg/systray/mock_platform.go +++ b/pkg/systray/mock_platform.go @@ -13,12 +13,13 @@ type exportedMockTray struct { tooltip, label string } -func (t *exportedMockTray) SetIcon(data []byte) { t.icon = data } -func (t *exportedMockTray) SetTemplateIcon(data []byte) { t.templateIcon = data } -func (t *exportedMockTray) SetTooltip(text string) { t.tooltip = text } -func (t *exportedMockTray) SetLabel(text string) { t.label = text } -func (t *exportedMockTray) SetMenu(menu PlatformMenu) {} -func (t *exportedMockTray) AttachWindow(w WindowHandle) {} +func (t *exportedMockTray) SetIcon(data []byte) { t.icon = data } +func (t *exportedMockTray) SetTemplateIcon(data []byte) { t.templateIcon = data } +func (t *exportedMockTray) SetTooltip(text string) { t.tooltip = text } +func (t *exportedMockTray) SetLabel(text string) { t.label = text } +func (t *exportedMockTray) SetMenu(menu PlatformMenu) {} +func (t *exportedMockTray) AttachWindow(w WindowHandle) {} +func (t *exportedMockTray) ShowMessage(title, message string) error { return nil } type exportedMockMenu struct { items []exportedMockMenuItem diff --git a/pkg/systray/mock_test.go b/pkg/systray/mock_test.go index 56f35cfc..9ab2f1c4 100644 --- a/pkg/systray/mock_test.go +++ b/pkg/systray/mock_test.go @@ -49,6 +49,8 @@ type mockTray struct { tooltip, label string menu PlatformMenu attachedWindow WindowHandle + lastMessageTitle string + lastMessageBody string } func (t *mockTray) SetIcon(data []byte) { t.icon = data } @@ -57,3 +59,8 @@ 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) ShowMessage(title, message string) error { + t.lastMessageTitle = title + t.lastMessageBody = message + return nil +} diff --git a/pkg/systray/platform.go b/pkg/systray/platform.go index b7494222..e113e82d 100644 --- a/pkg/systray/platform.go +++ b/pkg/systray/platform.go @@ -15,6 +15,7 @@ type PlatformTray interface { SetLabel(text string) SetMenu(menu PlatformMenu) AttachWindow(w WindowHandle) + ShowMessage(title, message string) error } // PlatformMenu is a tray menu built by the backend. diff --git a/pkg/systray/service.go b/pkg/systray/service.go index f585e7e3..7aad6ae1 100644 --- a/pkg/systray/service.go +++ b/pkg/systray/service.go @@ -48,8 +48,14 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { switch t := t.(type) { case TaskSetTrayIcon: return nil, true, s.manager.SetIcon(t.Data) + case TaskSetTrayTooltip: + return nil, true, s.manager.SetTooltip(t.Tooltip) + case TaskSetTrayLabel: + return nil, true, s.manager.SetLabel(t.Label) case TaskSetTrayMenu: return nil, true, s.taskSetTrayMenu(t) + case TaskShowMessage: + return nil, true, s.manager.ShowMessage(t.Title, t.Message) case TaskShowPanel: // Panel show — deferred (requires WindowHandle integration) return nil, true, nil diff --git a/pkg/systray/service_test.go b/pkg/systray/service_test.go index 4bcec301..419ccc50 100644 --- a/pkg/systray/service_test.go +++ b/pkg/systray/service_test.go @@ -54,6 +54,38 @@ func TestTaskSetTrayMenu_Good(t *testing.T) { assert.True(t, handled) } +func TestTaskSetTrayTooltip_Good(t *testing.T) { + svc, c := newTestSystrayService(t) + require.NoError(t, svc.manager.Setup("Test", "Test")) + + _, handled, err := c.PERFORM(TaskSetTrayTooltip{Tooltip: "Updated"}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, "Updated", svc.manager.Tray().(*mockTray).tooltip) +} + +func TestTaskSetTrayLabel_Good(t *testing.T) { + svc, c := newTestSystrayService(t) + require.NoError(t, svc.manager.Setup("Test", "Test")) + + _, handled, err := c.PERFORM(TaskSetTrayLabel{Label: "CoreGUI"}) + require.NoError(t, err) + assert.True(t, handled) + assert.Equal(t, "CoreGUI", svc.manager.Tray().(*mockTray).label) +} + +func TestTaskShowMessage_Good(t *testing.T) { + svc, c := newTestSystrayService(t) + require.NoError(t, svc.manager.Setup("Test", "Test")) + + _, handled, err := c.PERFORM(TaskShowMessage{Title: "Heads up", Message: "Background work finished"}) + require.NoError(t, err) + assert.True(t, handled) + tray := svc.manager.Tray().(*mockTray) + assert.Equal(t, "Heads up", tray.lastMessageTitle) + assert.Equal(t, "Background work finished", tray.lastMessageBody) +} + func TestTaskSetTrayIcon_Bad(t *testing.T) { // No systray service — PERFORM returns handled=false c, err := core.New(core.WithServiceLock()) diff --git a/pkg/systray/tray.go b/pkg/systray/tray.go index 817e1990..d8b17b63 100644 --- a/pkg/systray/tray.go +++ b/pkg/systray/tray.go @@ -87,6 +87,14 @@ func (m *Manager) AttachWindow(w WindowHandle) error { return nil } +// ShowMessage displays a tray message if the backend supports it. +func (m *Manager) ShowMessage(title, message string) error { + if m.tray == nil { + return coreerr.E("systray.ShowMessage", "tray not initialised", nil) + } + return m.tray.ShowMessage(title, message) +} + // Tray returns the underlying platform tray for direct access. func (m *Manager) Tray() PlatformTray { return m.tray diff --git a/pkg/systray/wails.go b/pkg/systray/wails.go index 47b69820..6ef93544 100644 --- a/pkg/systray/wails.go +++ b/pkg/systray/wails.go @@ -43,6 +43,12 @@ func (wt *wailsTray) AttachWindow(w WindowHandle) { // The caller must pass an appropriate wrapper. } +func (wt *wailsTray) ShowMessage(title, message string) error { + _ = title + _ = message + return nil +} + // wailsTrayMenu wraps *application.Menu for the PlatformMenu interface. type wailsTrayMenu struct { menu *application.Menu