diff --git a/pkg/display/display.go b/pkg/display/display.go index 7d6ddfb..236b068 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -237,6 +237,15 @@ type WSMessage struct { Data map[string]any `json:"data,omitempty"` } +// wsRequire extracts a string field from WS data and returns an error if it is empty. +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 v, nil +} + // handleWSMessage bridges WebSocket commands to IPC calls. func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) { var result any @@ -291,47 +300,98 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) { case "contextmenu:list": result, handled, err = s.Core().QUERY(contextmenu.QueryList{}) case "webview:eval": - w, _ := msg.Data["window"].(string) + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } script, _ := msg.Data["script"].(string) result, handled, err = s.Core().PERFORM(webview.TaskEvaluate{Window: w, Script: script}) case "webview:click": - w, _ := msg.Data["window"].(string) - sel, _ := msg.Data["selector"].(string) + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } + sel, e := wsRequire(msg.Data, "selector") + if e != nil { + return nil, false, e + } result, handled, err = s.Core().PERFORM(webview.TaskClick{Window: w, Selector: sel}) case "webview:type": - w, _ := msg.Data["window"].(string) - sel, _ := msg.Data["selector"].(string) + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } + sel, e := wsRequire(msg.Data, "selector") + if e != nil { + return nil, false, e + } text, _ := msg.Data["text"].(string) result, handled, err = s.Core().PERFORM(webview.TaskType{Window: w, Selector: sel, Text: text}) case "webview:navigate": - w, _ := msg.Data["window"].(string) - url, _ := msg.Data["url"].(string) + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } + url, e := wsRequire(msg.Data, "url") + if e != nil { + return nil, false, e + } result, handled, err = s.Core().PERFORM(webview.TaskNavigate{Window: w, URL: url}) case "webview:screenshot": - w, _ := msg.Data["window"].(string) + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } result, handled, err = s.Core().PERFORM(webview.TaskScreenshot{Window: w}) case "webview:scroll": - w, _ := msg.Data["window"].(string) + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } x, _ := msg.Data["x"].(float64) y, _ := msg.Data["y"].(float64) result, handled, err = s.Core().PERFORM(webview.TaskScroll{Window: w, X: int(x), Y: int(y)}) case "webview:hover": - w, _ := msg.Data["window"].(string) - sel, _ := msg.Data["selector"].(string) + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } + sel, e := wsRequire(msg.Data, "selector") + if e != nil { + return nil, false, e + } result, handled, err = s.Core().PERFORM(webview.TaskHover{Window: w, Selector: sel}) case "webview:select": - w, _ := msg.Data["window"].(string) - sel, _ := msg.Data["selector"].(string) + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } + sel, e := wsRequire(msg.Data, "selector") + if e != nil { + return nil, false, e + } val, _ := msg.Data["value"].(string) result, handled, err = s.Core().PERFORM(webview.TaskSelect{Window: w, Selector: sel, Value: val}) case "webview:check": - w, _ := msg.Data["window"].(string) - sel, _ := msg.Data["selector"].(string) + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } + sel, e := wsRequire(msg.Data, "selector") + if e != nil { + return nil, false, e + } checked, _ := msg.Data["checked"].(bool) result, handled, err = s.Core().PERFORM(webview.TaskCheck{Window: w, Selector: sel, Checked: checked}) case "webview:upload": - w, _ := msg.Data["window"].(string) - sel, _ := msg.Data["selector"].(string) + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } + sel, e := wsRequire(msg.Data, "selector") + if e != nil { + return nil, false, e + } pathsRaw, _ := msg.Data["paths"].([]any) var paths []string for _, p := range pathsRaw { @@ -341,15 +401,24 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) { } result, handled, err = s.Core().PERFORM(webview.TaskUploadFile{Window: w, Selector: sel, Paths: paths}) case "webview:viewport": - w, _ := msg.Data["window"].(string) + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } width, _ := msg.Data["width"].(float64) height, _ := msg.Data["height"].(float64) result, handled, err = s.Core().PERFORM(webview.TaskSetViewport{Window: w, Width: int(width), Height: int(height)}) case "webview:clear-console": - w, _ := msg.Data["window"].(string) + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } result, handled, err = s.Core().PERFORM(webview.TaskClearConsole{Window: w}) case "webview:console": - w, _ := msg.Data["window"].(string) + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } level, _ := msg.Data["level"].(string) limit := 100 if l, ok := msg.Data["limit"].(float64); ok { @@ -357,22 +426,43 @@ func (s *Service) handleWSMessage(msg WSMessage) (any, bool, error) { } result, handled, err = s.Core().QUERY(webview.QueryConsole{Window: w, Level: level, Limit: limit}) case "webview:query": - w, _ := msg.Data["window"].(string) - sel, _ := msg.Data["selector"].(string) + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } + sel, e := wsRequire(msg.Data, "selector") + if e != nil { + return nil, false, e + } result, handled, err = s.Core().QUERY(webview.QuerySelector{Window: w, Selector: sel}) case "webview:query-all": - w, _ := msg.Data["window"].(string) - sel, _ := msg.Data["selector"].(string) + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } + sel, e := wsRequire(msg.Data, "selector") + if e != nil { + return nil, false, e + } result, handled, err = s.Core().QUERY(webview.QuerySelectorAll{Window: w, Selector: sel}) case "webview:dom-tree": - w, _ := msg.Data["window"].(string) - sel, _ := msg.Data["selector"].(string) + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } + sel, _ := msg.Data["selector"].(string) // selector optional for dom-tree (defaults to root) result, handled, err = s.Core().QUERY(webview.QueryDOMTree{Window: w, Selector: sel}) case "webview:url": - w, _ := msg.Data["window"].(string) + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } result, handled, err = s.Core().QUERY(webview.QueryURL{Window: w}) case "webview:title": - w, _ := msg.Data["window"].(string) + w, e := wsRequire(msg.Data, "window") + if e != nil { + return nil, false, e + } result, handled, err = s.Core().QUERY(webview.QueryTitle{Window: w}) default: return nil, false, nil @@ -685,7 +775,7 @@ func (s *Service) GetWindowTitle(name string) (string, error) { if info == nil { return "", fmt.Errorf("window not found: %s", name) } - return info.Name, nil // Wails v3 doesn't expose a title getter + return info.Title, nil } // ResetWindowState clears saved window positions. diff --git a/pkg/window/messages.go b/pkg/window/messages.go index f663b4b..b5d1a13 100644 --- a/pkg/window/messages.go +++ b/pkg/window/messages.go @@ -3,6 +3,7 @@ package window // WindowInfo contains information about a window. type WindowInfo struct { Name string `json:"name"` + Title string `json:"title"` X int `json:"x"` Y int `json:"y"` Width int `json:"width"` @@ -52,6 +53,58 @@ 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 } diff --git a/pkg/window/mock_platform.go b/pkg/window/mock_platform.go index f460d77..9dde9a6 100644 --- a/pkg/window/mock_platform.go +++ b/pkg/window/mock_platform.go @@ -39,6 +39,7 @@ type MockWindow struct { } 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 } diff --git a/pkg/window/mock_test.go b/pkg/window/mock_test.go index 4b989ef..72d54ca 100644 --- a/pkg/window/mock_test.go +++ b/pkg/window/mock_test.go @@ -38,6 +38,7 @@ type mockWindow struct { } 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 } diff --git a/pkg/window/platform.go b/pkg/window/platform.go index f8e62ee..ae4e2e6 100644 --- a/pkg/window/platform.go +++ b/pkg/window/platform.go @@ -28,6 +28,7 @@ type PlatformWindowOptions struct { type PlatformWindow interface { // Identity Name() string + Title() string // Queries Position() (int, int) diff --git a/pkg/window/service.go b/pkg/window/service.go index 85286e5..040ab95 100644 --- a/pkg/window/service.go +++ b/pkg/window/service.go @@ -68,6 +68,14 @@ 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 QueryLayoutList: + return s.manager.Layout().ListLayouts(), true, nil + case QueryLayoutGet: + l, ok := s.manager.Layout().GetLayout(q.Name) + if !ok { + return (*Layout)(nil), true, nil + } + return &l, true, nil default: return nil, false, nil } @@ -81,7 +89,7 @@ func (s *Service) queryWindowList() []WindowInfo { x, y := pw.Position() w, h := pw.Size() result = append(result, WindowInfo{ - Name: name, X: x, Y: y, Width: w, Height: h, + Name: name, Title: pw.Title(), X: x, Y: y, Width: w, Height: h, Maximized: pw.IsMaximised(), Focused: pw.IsFocused(), }) @@ -98,7 +106,7 @@ func (s *Service) queryWindowByName(name string) *WindowInfo { x, y := pw.Position() w, h := pw.Size() return &WindowInfo{ - Name: name, X: x, Y: y, Width: w, Height: h, + Name: name, Title: pw.Title(), X: x, Y: y, Width: w, Height: h, Maximized: pw.IsMaximised(), Focused: pw.IsFocused(), } @@ -122,6 +130,25 @@ func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) { return nil, true, s.taskMinimise(t.Name) case TaskFocus: return nil, true, s.taskFocus(t.Name) + case TaskRestore: + return nil, true, s.taskRestore(t.Name) + case TaskSetTitle: + return nil, true, s.taskSetTitle(t.Name, t.Title) + case TaskSetVisibility: + return nil, true, s.taskSetVisibility(t.Name, t.Visible) + case TaskFullscreen: + return nil, true, s.taskFullscreen(t.Name, t.Fullscreen) + case TaskSaveLayout: + return nil, true, s.taskSaveLayout(t.Name) + case TaskRestoreLayout: + return nil, true, s.taskRestoreLayout(t.Name) + case TaskDeleteLayout: + s.manager.Layout().DeleteLayout(t.Name) + return nil, true, nil + case TaskTileWindows: + return nil, true, s.taskTileWindows(t.Mode, t.Windows) + case TaskSnapWindow: + return nil, true, s.taskSnapWindow(t.Name, t.Position) default: return nil, false, nil } @@ -134,7 +161,7 @@ func (s *Service) taskOpenWindow(t TaskOpenWindow) (any, bool, error) { } x, y := pw.Position() w, h := pw.Size() - info := WindowInfo{Name: pw.Name(), X: x, Y: y, Width: w, Height: h} + info := WindowInfo{Name: pw.Name(), Title: pw.Title(), X: x, Y: y, Width: w, Height: h} // Attach platform event listeners that convert to IPC actions s.trackWindow(pw) @@ -238,6 +265,114 @@ func (s *Service) taskFocus(name string) error { return nil } +func (s *Service) taskRestore(name string) error { + pw, ok := s.manager.Get(name) + if !ok { + return fmt.Errorf("window not found: %s", name) + } + pw.Restore() + s.manager.State().UpdateMaximized(name, false) + return nil +} + +func (s *Service) taskSetTitle(name, title string) error { + pw, ok := s.manager.Get(name) + if !ok { + return fmt.Errorf("window not found: %s", name) + } + pw.SetTitle(title) + return nil +} + +func (s *Service) taskSetVisibility(name string, visible bool) error { + pw, ok := s.manager.Get(name) + if !ok { + return fmt.Errorf("window not found: %s", name) + } + pw.SetVisibility(visible) + return nil +} + +func (s *Service) taskFullscreen(name string, fullscreen bool) error { + pw, ok := s.manager.Get(name) + if !ok { + return fmt.Errorf("window not found: %s", name) + } + if fullscreen { + pw.Fullscreen() + } else { + pw.UnFullscreen() + } + return nil +} + +func (s *Service) taskSaveLayout(name string) error { + windows := s.queryWindowList() + states := make(map[string]WindowState, len(windows)) + for _, w := range windows { + states[w.Name] = WindowState{ + X: w.X, Y: w.Y, Width: w.Width, Height: w.Height, + Maximized: w.Maximized, + } + } + return s.manager.Layout().SaveLayout(name, states) +} + +func (s *Service) taskRestoreLayout(name string) error { + layout, ok := s.manager.Layout().GetLayout(name) + if !ok { + return fmt.Errorf("layout not found: %s", name) + } + for winName, state := range layout.Windows { + pw, found := s.manager.Get(winName) + if !found { + continue + } + pw.SetPosition(state.X, state.Y) + pw.SetSize(state.Width, state.Height) + if state.Maximized { + pw.Maximise() + } + } + return nil +} + +var tileModeMap = map[string]TileMode{ + "left-half": TileModeLeftHalf, "right-half": TileModeRightHalf, + "top-half": TileModeTopHalf, "bottom-half": TileModeBottomHalf, + "top-left": TileModeTopLeft, "top-right": TileModeTopRight, + "bottom-left": TileModeBottomLeft, "bottom-right": TileModeBottomRight, + "left-right": TileModeLeftRight, "grid": TileModeGrid, +} + +func (s *Service) taskTileWindows(mode string, names []string) error { + tm, ok := tileModeMap[mode] + if !ok { + return fmt.Errorf("unknown tile mode: %s", mode) + } + 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) +} + +var snapPosMap = map[string]SnapPosition{ + "left": SnapLeft, "right": SnapRight, + "top": SnapTop, "bottom": SnapBottom, + "top-left": SnapTopLeft, "top-right": SnapTopRight, + "bottom-left": SnapBottomLeft, "bottom-right": SnapBottomRight, + "center": SnapCenter, "centre": SnapCenter, +} + +func (s *Service) taskSnapWindow(name, position string) error { + pos, ok := snapPosMap[position] + if !ok { + return fmt.Errorf("unknown snap position: %s", position) + } + return s.manager.SnapWindow(name, pos, 1920, 1080) +} + // Manager returns the underlying window Manager for direct access. func (s *Service) Manager() *Manager { return s.manager diff --git a/pkg/window/wails.go b/pkg/window/wails.go index 7d9fbb1..1d2a722 100644 --- a/pkg/window/wails.go +++ b/pkg/window/wails.go @@ -37,7 +37,7 @@ func (wp *WailsPlatform) CreateWindow(opts PlatformWindowOptions) PlatformWindow BackgroundColour: application.NewRGBA(opts.BackgroundColour[0], opts.BackgroundColour[1], opts.BackgroundColour[2], opts.BackgroundColour[3]), } w := wp.app.Window.NewWithOptions(wOpts) - return &wailsWindow{w: w} + return &wailsWindow{w: w, title: opts.Title} } func (wp *WailsPlatform) GetWindows() []PlatformWindow { @@ -52,16 +52,19 @@ func (wp *WailsPlatform) GetWindows() []PlatformWindow { } // wailsWindow wraps *application.WebviewWindow to implement PlatformWindow. +// It stores the title locally because Wails v3 does not expose a title getter. type wailsWindow struct { - w *application.WebviewWindow + w *application.WebviewWindow + 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.w.SetTitle(title) } +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) {