gui/pkg/window/service.go
Virgil f854b65720
Some checks failed
Security Scan / security (push) Failing after 30s
Test / test (push) Successful in 1m16s
refactor(ax): align WebSocket and window naming
Co-Authored-By: Virgil <virgil@lethean.io>
2026-03-31 07:23:22 +00:00

490 lines
14 KiB
Go

package window
import (
"context"
coreerr "forge.lthn.ai/core/go-log"
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/gui/pkg/screen"
)
type Options struct{}
type Service struct {
*core.ServiceRuntime[Options]
manager *Manager
platform Platform
}
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.
configValue, handled, _ := s.Core().QUERY(QueryConfig{})
if handled {
if windowConfig, ok := configValue.(map[string]any); ok {
s.applyConfig(windowConfig)
}
}
s.Core().RegisterQuery(s.handleQuery)
s.Core().RegisterTask(s.handleTask)
return nil
}
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 height, ok := configData["default_height"]; ok {
if height, ok := height.(int); ok {
s.manager.SetDefaultHeight(height)
}
}
if stateFile, ok := configData["state_file"]; ok {
if stateFile, ok := stateFile.(string); ok {
s.manager.State().SetPath(stateFile)
}
}
}
func (s *Service) requireWindow(name string, operation string) (PlatformWindow, error) {
platformWindow, ok := s.manager.Get(name)
if !ok {
return nil, coreerr.E(operation, "window not found: "+name, nil)
}
return platformWindow, nil
}
func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error {
return nil
}
func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) {
switch q := q.(type) {
case QueryWindowList:
return s.queryWindowList(), true, nil
case QueryWindowByName:
return s.queryWindowByName(q.Name), true, nil
case QuerySavedWindowStates:
return s.querySavedWindowStates(), 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
}
}
func (s *Service) queryWindowList() []WindowInfo {
names := s.manager.List()
result := make([]WindowInfo, 0, len(names))
for _, name := range names {
if platformWindow, ok := s.manager.Get(name); ok {
x, y := platformWindow.Position()
width, height := platformWindow.Size()
result = append(result, WindowInfo{
Name: name, Title: platformWindow.Title(), X: x, Y: y, Width: width, Height: height,
Maximized: platformWindow.IsMaximised(),
Focused: platformWindow.IsFocused(),
})
}
}
return result
}
func (s *Service) queryWindowByName(name string) *WindowInfo {
platformWindow, ok := s.manager.Get(name)
if !ok {
return nil
}
x, y := platformWindow.Position()
width, height := platformWindow.Size()
return &WindowInfo{
Name: name, Title: platformWindow.Title(), X: x, Y: y, Width: width, Height: height,
Maximized: platformWindow.IsMaximised(),
Focused: platformWindow.IsFocused(),
}
}
func (s *Service) querySavedWindowStates() map[string]WindowState {
stateNames := s.manager.State().ListStates()
result := make(map[string]WindowState, len(stateNames))
for _, name := range stateNames {
if state, ok := s.manager.State().GetState(name); ok {
result[name] = state
}
}
return result
}
// --- Task Handlers ---
func (s *Service) handleTask(c *core.Core, t core.Task) (any, bool, error) {
switch t := t.(type) {
case TaskOpenWindow:
return s.taskOpenWindow(t)
case TaskCloseWindow:
return nil, true, s.taskCloseWindow(t.Name)
case TaskSetPosition:
return nil, true, s.taskSetPosition(t.Name, t.X, t.Y)
case TaskSetSize:
return nil, true, s.taskSetSize(t.Name, t.Width, t.Height)
case TaskMaximize:
return nil, true, s.taskMaximize(t.Name)
case TaskMinimize:
return nil, true, s.taskMinimize(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 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:
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 TaskResetWindowState:
s.manager.State().Clear()
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) 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 fallbackX, fallbackY, fallbackWidth, fallbackHeight
}
primary, ok := result.(*screen.Screen)
if !ok || primary == nil {
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 fallbackX, fallbackY, fallbackWidth, fallbackHeight
}
return x, y, width, height
}
func (s *Service) taskOpenWindow(t TaskOpenWindow) (any, bool, error) {
platformWindow, err := s.manager.Create(t.Window)
if err != nil {
return nil, true, err
}
x, y := platformWindow.Position()
width, height := platformWindow.Size()
info := WindowInfo{Name: platformWindow.Name(), Title: platformWindow.Title(), X: x, Y: y, Width: width, Height: height}
// Attach platform event listeners that convert to IPC actions
s.trackWindow(platformWindow)
// Broadcast to all listeners
_ = s.Core().ACTION(ActionWindowOpened{Name: platformWindow.Name()})
return info, true, nil
}
// trackWindow attaches platform event listeners that emit IPC actions.
func (s *Service) trackWindow(platformWindow PlatformWindow) {
platformWindow.OnWindowEvent(func(event WindowEvent) {
switch event.Type {
case "focus":
_ = s.Core().ACTION(ActionWindowFocused{Name: event.Name})
case "blur":
_ = s.Core().ACTION(ActionWindowBlurred{Name: event.Name})
case "move":
if data := event.Data; data != nil {
x, _ := data["x"].(int)
y, _ := data["y"].(int)
_ = s.Core().ACTION(ActionWindowMoved{Name: event.Name, X: x, Y: y})
}
case "resize":
if data := event.Data; data != nil {
width, _ := data["width"].(int)
if width == 0 {
width, _ = data["w"].(int)
}
height, _ := data["height"].(int)
if height == 0 {
height, _ = data["h"].(int)
}
_ = s.Core().ACTION(ActionWindowResized{Name: event.Name, Width: width, Height: height})
}
case "close":
_ = s.Core().ACTION(ActionWindowClosed{Name: event.Name})
}
})
platformWindow.OnFileDrop(func(paths []string, targetID string) {
_ = s.Core().ACTION(ActionFilesDropped{
Name: platformWindow.Name(),
Paths: paths,
TargetID: targetID,
})
})
}
func (s *Service) taskCloseWindow(name string) error {
platformWindow, err := s.requireWindow(name, "window.Service.taskCloseWindow")
if err != nil {
return err
}
// Persist state BEFORE closing (spec requirement)
s.manager.State().CaptureState(platformWindow)
platformWindow.Close()
s.manager.Remove(name)
_ = s.Core().ACTION(ActionWindowClosed{Name: name})
return nil
}
func (s *Service) taskSetPosition(name string, x, y int) error {
platformWindow, err := s.requireWindow(name, "window.Service.taskSetPosition")
if err != nil {
return err
}
platformWindow.SetPosition(x, y)
s.manager.State().UpdatePosition(name, x, y)
return nil
}
func (s *Service) taskSetSize(name string, width, height int) error {
platformWindow, err := s.requireWindow(name, "window.Service.taskSetSize")
if err != nil {
return err
}
platformWindow.SetSize(width, height)
s.manager.State().UpdateSize(name, width, height)
return nil
}
func (s *Service) taskMaximize(name string) error {
platformWindow, err := s.requireWindow(name, "window.Service.taskMaximize")
if err != nil {
return err
}
platformWindow.Maximise()
s.manager.State().UpdateMaximized(name, true)
return nil
}
func (s *Service) taskMinimize(name string) error {
platformWindow, err := s.requireWindow(name, "window.Service.taskMinimize")
if err != nil {
return err
}
platformWindow.Minimise()
return nil
}
func (s *Service) taskFocus(name string) error {
platformWindow, err := s.requireWindow(name, "window.Service.taskFocus")
if err != nil {
return err
}
platformWindow.Focus()
return nil
}
func (s *Service) taskRestore(name string) error {
platformWindow, err := s.requireWindow(name, "window.Service.taskRestore")
if err != nil {
return err
}
platformWindow.Restore()
s.manager.State().UpdateMaximized(name, false)
return nil
}
func (s *Service) taskSetTitle(name, title string) error {
platformWindow, err := s.requireWindow(name, "window.Service.taskSetTitle")
if err != nil {
return err
}
platformWindow.SetTitle(title)
return nil
}
func (s *Service) taskSetAlwaysOnTop(name string, alwaysOnTop bool) error {
platformWindow, err := s.requireWindow(name, "window.Service.taskSetAlwaysOnTop")
if err != nil {
return err
}
platformWindow.SetAlwaysOnTop(alwaysOnTop)
return nil
}
func (s *Service) taskSetBackgroundColour(name string, red, green, blue, alpha uint8) error {
platformWindow, err := s.requireWindow(name, "window.Service.taskSetBackgroundColour")
if err != nil {
return err
}
platformWindow.SetBackgroundColour(red, green, blue, alpha)
return nil
}
func (s *Service) taskSetVisibility(name string, visible bool) error {
platformWindow, err := s.requireWindow(name, "window.Service.taskSetVisibility")
if err != nil {
return err
}
platformWindow.SetVisibility(visible)
return nil
}
func (s *Service) taskFullscreen(name string, fullscreen bool) error {
platformWindow, err := s.requireWindow(name, "window.Service.taskFullscreen")
if err != nil {
return err
}
if fullscreen {
platformWindow.Fullscreen()
} else {
platformWindow.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 coreerr.E("window.Service.taskRestoreLayout", "layout not found: "+name, nil)
}
for winName, state := range layout.Windows {
platformWindow, found := s.manager.Get(winName)
if !found {
continue
}
platformWindow.SetPosition(state.X, state.Y)
platformWindow.SetSize(state.Width, state.Height)
if state.Maximized {
platformWindow.Maximise()
} else {
platformWindow.Restore()
}
s.manager.State().CaptureState(platformWindow)
}
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 coreerr.E("window.Service.taskTileWindows", "unknown tile mode: "+mode, nil)
}
if len(names) == 0 {
names = s.manager.List()
}
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{
"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 coreerr.E("window.Service.taskSnapWindow", "unknown snap position: "+position, nil)
}
originX, originY, screenWidth, screenHeight := s.primaryScreenArea()
return s.manager.SnapWindow(name, pos, screenWidth, screenHeight, originX, originY)
}
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 coreerr.E("window.Service.taskApplyWorkflow", "unknown workflow layout: "+workflow, nil)
}
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.
func (s *Service) Manager() *Manager {
return s.manager
}