From a07fa49c208ede8d1d1aa67131491f4a1f926311 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 14:03:29 +0000 Subject: [PATCH] feat(gui): add missing window mutators and MCP tools Co-Authored-By: Virgil --- pkg/display/display.go | 32 ++++++++----- pkg/mcp/tools_window.go | 96 ++++++++++++++++++++++++++++++------- pkg/window/messages.go | 41 ++++++++++++++-- pkg/window/mock_platform.go | 6 ++- pkg/window/mock_test.go | 6 ++- pkg/window/service.go | 58 +++++++++++++++++++--- pkg/window/service_test.go | 42 ++++++++++++++++ 7 files changed, 237 insertions(+), 44 deletions(-) diff --git a/pkg/display/display.go b/pkg/display/display.go index 9803012..ec4f281 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -1243,13 +1243,15 @@ func (s *Service) GetSavedWindowStates() map[string]window.WindowState { // CreateWindowOptions contains options for creating a new window. type CreateWindowOptions struct { - Name string `json:"name"` - Title string `json:"title,omitempty"` - URL string `json:"url,omitempty"` - X int `json:"x,omitempty"` - Y int `json:"y,omitempty"` - Width int `json:"width,omitempty"` - Height int `json:"height,omitempty"` + Name string `json:"name"` + Title string `json:"title,omitempty"` + URL string `json:"url,omitempty"` + X int `json:"x,omitempty"` + Y int `json:"y,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + AlwaysOnTop bool `json:"alwaysOnTop,omitempty"` + BackgroundColour [4]uint8 `json:"backgroundColour,omitempty"` } // CreateWindow creates a new window with the specified options. @@ -1258,12 +1260,16 @@ func (s *Service) CreateWindow(opts CreateWindowOptions) (*window.WindowInfo, er return nil, fmt.Errorf("window name is required") } 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), + Window: &window.Window{ + Name: opts.Name, + Title: opts.Title, + URL: opts.URL, + X: opts.X, + Y: opts.Y, + Width: opts.Width, + Height: opts.Height, + AlwaysOnTop: opts.AlwaysOnTop, + BackgroundColour: opts.BackgroundColour, }, }) if err != nil { diff --git a/pkg/mcp/tools_window.go b/pkg/mcp/tools_window.go index e5ac73f..b735ed0 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) { - opts := []window.WindowOption{ - window.WithName(input.Name), - } - if input.Title != "" { - opts = append(opts, window.WithTitle(input.Title)) - } - if input.URL != "" { - opts = append(opts, window.WithURL(input.URL)) - } - if input.Width > 0 || input.Height > 0 { - opts = append(opts, window.WithSize(input.Width, input.Height)) - } - if input.X != 0 || input.Y != 0 { - opts = append(opts, window.WithPosition(input.X, input.Y)) - } - result, _, err := s.core.PERFORM(window.TaskOpenWindow{Opts: opts}) + 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 } @@ -163,7 +158,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 +183,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 } @@ -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/window/messages.go b/pkg/window/messages.go index 814250e..a7fabd5 100644 --- a/pkg/window/messages.go +++ b/pkg/window/messages.go @@ -12,6 +12,14 @@ type WindowInfo struct { Focused bool `json:"focused"` } +// Bounds describes the position and size of a window. +type Bounds struct { + X int `json:"x"` + Y int `json:"y"` + Width int `json:"width"` + Height int `json:"height"` +} + // --- Queries (read-only) --- // QueryWindowList returns all tracked windows. Result: []WindowInfo @@ -24,6 +32,9 @@ type QueryWindowByName struct{ Name string } // Result: map[string]any type QueryConfig struct{} +// QueryWindowBounds returns the current bounds for a window. +type QueryWindowBounds struct{ Name string } + // QueryFindSpace returns a suggested free placement for a new window. type QueryFindSpace struct { Width int @@ -40,7 +51,10 @@ type QueryLayoutSuggestion struct { // --- Tasks (side-effects) --- // TaskOpenWindow creates a new window. Result: WindowInfo -type TaskOpenWindow struct{ Opts []WindowOption } +type TaskOpenWindow struct { + Window *Window + Opts []WindowOption +} // TaskCloseWindow closes a window. Handler persists state BEFORE emitting ActionWindowClosed. type TaskCloseWindow struct{ Name string } @@ -53,8 +67,9 @@ type TaskSetPosition struct { // TaskSetSize resizes a window. type TaskSetSize struct { - Name string - W, H int + Name string + Width, Height int + W, H int } // TaskMaximise maximises a window. @@ -75,6 +90,21 @@ type TaskSetTitle struct { Title string } +// TaskSetAlwaysOnTop pins a window above others. +type TaskSetAlwaysOnTop struct { + Name string + AlwaysOnTop bool +} + +// TaskSetBackgroundColour updates the window background colour. +type TaskSetBackgroundColour struct { + Name string + Red uint8 + Green uint8 + Blue uint8 + Alpha uint8 +} + // TaskSetVisibility shows or hides a window. type TaskSetVisibility struct { Name string @@ -157,8 +187,9 @@ type ActionWindowMoved struct { } type ActionWindowResized struct { - Name string - W, H int + Name string + Width, Height int + W, H int } type ActionWindowFocused struct{ Name string } diff --git a/pkg/window/mock_platform.go b/pkg/window/mock_platform.go index 41c4052..d79bcc1 100644 --- a/pkg/window/mock_platform.go +++ b/pkg/window/mock_platform.go @@ -15,6 +15,9 @@ func (m *MockPlatform) CreateWindow(opts PlatformWindowOptions) PlatformWindow { name: opts.Name, title: opts.Title, url: opts.URL, width: opts.Width, height: opts.Height, x: opts.X, y: opts.Y, + alwaysOnTop: opts.AlwaysOnTop, + backgroundColor: opts.BackgroundColour, + visible: !opts.Hidden, } m.Windows = append(m.Windows, w) return w @@ -33,6 +36,7 @@ type MockWindow struct { width, height, x, y int maximised, focused bool visible, alwaysOnTop bool + backgroundColor [4]uint8 closed bool eventHandlers []func(WindowEvent) fileDropHandlers []func(paths []string, targetID string) @@ -47,7 +51,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.backgroundColor = [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 d77602d..df23b21 100644 --- a/pkg/window/mock_test.go +++ b/pkg/window/mock_test.go @@ -14,6 +14,9 @@ func (m *mockPlatform) CreateWindow(opts PlatformWindowOptions) PlatformWindow { name: opts.Name, title: opts.Title, url: opts.URL, width: opts.Width, height: opts.Height, x: opts.X, y: opts.Y, + alwaysOnTop: opts.AlwaysOnTop, + backgroundColor: opts.BackgroundColour, + visible: !opts.Hidden, } m.windows = append(m.windows, w) return w @@ -32,6 +35,7 @@ type mockWindow struct { width, height, x, y int maximised, focused bool visible, alwaysOnTop bool + backgroundColor [4]uint8 closed bool eventHandlers []func(WindowEvent) fileDropHandlers []func(paths []string, targetID string) @@ -46,7 +50,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.backgroundColor = [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 fa5f97c..4d3f4d2 100644 --- a/pkg/window/service.go +++ b/pkg/window/service.go @@ -70,6 +70,11 @@ 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 QueryWindowBounds: + if info := s.queryWindowByName(q.Name); info != nil { + return &Bounds{X: info.X, Y: info.Y, Width: info.Width, Height: info.Height}, true, nil + } + return (*Bounds)(nil), true, nil case QueryLayoutList: return s.manager.Layout().ListLayouts(), true, nil case QueryLayoutGet: @@ -131,7 +136,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, t.W, t.H) case TaskMaximise: return nil, true, s.taskMaximise(t.Name) case TaskMinimise: @@ -142,6 +147,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: @@ -171,7 +180,16 @@ 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...) + var ( + pw PlatformWindow + err error + ) + if t.Window != nil { + spec := *t.Window + pw, err = s.manager.Create(&spec) + } else { + pw, err = s.manager.Open(t.Opts...) + } if err != nil { return nil, true, err } @@ -205,7 +223,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, W: w, H: h}) } case "close": _ = s.Core().ACTION(ActionWindowClosed{Name: e.Name}) @@ -243,13 +261,23 @@ 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, legacyWidth, legacyHeight 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) + if width == 0 && height == 0 { + width, height = legacyWidth, legacyHeight + } else { + if width == 0 { + width = legacyWidth + } + if height == 0 { + height = legacyHeight + } + } + pw.SetSize(width, height) + s.manager.State().UpdateSize(name, width, height) return nil } @@ -300,6 +328,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 { diff --git a/pkg/window/service_test.go b/pkg/window/service_test.go index 6b41a3f..57e79a0 100644 --- a/pkg/window/service_test.go +++ b/pkg/window/service_test.go @@ -54,6 +54,18 @@ func TestTaskOpenWindow_Good(t *testing.T) { assert.Equal(t, "test", info.Name) } +func TestTaskOpenWindowDescriptor_Good(t *testing.T) { + _, c := newTestWindowService(t) + result, handled, err := c.PERFORM(TaskOpenWindow{ + Window: &Window{Name: "descriptor", Title: "Descriptor", Width: 640, Height: 480}, + }) + require.NoError(t, err) + assert.True(t, handled) + info := result.(WindowInfo) + assert.Equal(t, "descriptor", info.Name) + assert.Equal(t, "Descriptor", info.Title) +} + func TestTaskOpenWindow_Bad(t *testing.T) { // No window service registered — PERFORM returns handled=false c, err := core.New(core.WithServiceLock()) @@ -141,6 +153,36 @@ func TestTaskSetSize_Good(t *testing.T) { assert.Equal(t, 600, info.Height) } +func TestTaskSetAlwaysOnTop_Good(t *testing.T) { + _, c := newTestWindowService(t) + _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("test")}}) + + _, handled, err := c.PERFORM(TaskSetAlwaysOnTop{Name: "test", AlwaysOnTop: true}) + require.NoError(t, err) + assert.True(t, handled) + + svc := core.MustServiceFor[*Service](c, "window") + pw, ok := svc.Manager().Get("test") + require.True(t, ok) + assert.True(t, pw.(*mockWindow).alwaysOnTop) +} + +func TestTaskSetBackgroundColour_Good(t *testing.T) { + _, c := newTestWindowService(t) + _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []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) + + svc := core.MustServiceFor[*Service](c, "window") + pw, ok := svc.Manager().Get("test") + require.True(t, ok) + assert.Equal(t, [4]uint8{10, 20, 30, 40}, pw.(*mockWindow).backgroundColor) +} + func TestTaskStackWindows_Good(t *testing.T) { _, c := newTestWindowService(t) _, _, _ = c.PERFORM(TaskOpenWindow{Opts: []WindowOption{WithName("one")}})