gui/pkg/window/service.go
Snider 62ec735c10
Some checks failed
Security Scan / security (push) Has been cancelled
Test / test (push) Has been cancelled
refactor: AX compliance sweep — replace banned stdlib imports with core primitives
Replaced fmt, strings, sort, os, io, sync, encoding/json, path/filepath,
errors, log, reflect with core.Sprintf, core.E, core.Contains, core.Trim,
core.Split, core.Join, core.JoinPath, slices.Sort, c.Fs(), c.Lock(),
core.JSONMarshal, core.ReadAll and other CoreGO v0.8.0 primitives.

Framework boundary exceptions preserved where stdlib types are required
by external interfaces (Gin, net/http, CGo, Wails, bubbletea).

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-13 09:32:01 +01:00

587 lines
16 KiB
Go

// pkg/window/service.go
package window
import (
"context"
corego "dappco.re/go/core"
"dappco.re/go/core/gui/pkg/screen"
"forge.lthn.ai/core/go/pkg/core"
)
// Options holds configuration for the window service.
// Use: svc, err := window.Register(platform)(core.New())
type Options struct{}
// Service is a core.Service managing window lifecycle via IPC.
// Use: core.WithService(window.Register(window.NewMockPlatform()))
// It embeds ServiceRuntime for Core access and composes Manager for platform operations.
// Use: svc, err := window.Register(platform)(core.New())
type Service struct {
*core.ServiceRuntime[Options]
manager *Manager
platform Platform
}
// OnStartup queries config from the display orchestrator and registers IPC handlers.
// Use: _ = svc.OnStartup(context.Background())
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{})
if handled {
if wCfg, ok := cfg.(map[string]any); ok {
s.applyConfig(wCfg)
}
}
// 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 width, ok := cfg["default_width"]; ok {
if width, ok := width.(int); ok {
s.manager.SetDefaultWidth(width)
}
}
if height, ok := cfg["default_height"]; ok {
if height, ok := height.(int); ok {
s.manager.SetDefaultHeight(height)
}
}
if stateFile, ok := cfg["state_file"]; ok {
if stateFile, ok := stateFile.(string); ok {
s.manager.State().SetPath(stateFile)
}
}
}
// HandleIPCEvents is auto-discovered and registered by core.WithService.
// Use: _ = svc.HandleIPCEvents(core, msg)
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:
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:
l, ok := s.manager.Layout().GetLayout(q.Name)
if !ok {
return (*Layout)(nil), true, nil
}
return &l, true, nil
case QueryFindSpace:
screenW, screenH := q.ScreenWidth, q.ScreenHeight
if screenW <= 0 || screenH <= 0 {
screenW, screenH = s.primaryScreenSize()
}
return s.manager.FindSpace(screenW, screenH, q.Width, q.Height), true, nil
case QueryLayoutSuggestion:
screenW, screenH := q.ScreenWidth, q.ScreenHeight
if screenW <= 0 || screenH <= 0 {
screenW, screenH = s.primaryScreenSize()
}
return s.manager.SuggestLayout(screenW, screenH, q.WindowCount), 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 pw, ok := s.manager.Get(name); ok {
x, y := pw.Position()
w, h := pw.Size()
result = append(result, WindowInfo{
Name: name,
Title: pw.Title(),
X: x,
Y: y,
Width: w,
Height: h,
Visible: pw.IsVisible(),
Minimized: pw.IsMinimised(),
Maximized: pw.IsMaximised(),
Focused: pw.IsFocused(),
})
}
}
return result
}
func (s *Service) queryWindowByName(name string) *WindowInfo {
pw, ok := s.manager.Get(name)
if !ok {
return nil
}
x, y := pw.Position()
w, h := pw.Size()
return &WindowInfo{
Name: name,
Title: pw.Title(),
X: x,
Y: y,
Width: w,
Height: h,
Visible: pw.IsVisible(),
Minimized: pw.IsMinimised(),
Maximized: pw.IsMaximised(),
Focused: pw.IsFocused(),
}
}
// --- 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, t.W, t.H)
case TaskMaximise:
return nil, true, s.taskMaximise(t.Name)
case TaskMinimise:
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 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 TaskSetOpacity:
return nil, true, s.taskSetOpacity(t.Name, t.Opacity)
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)
case TaskArrangePair:
return nil, true, s.taskArrangePair(t.First, t.Second)
case TaskBesideEditor:
return nil, true, s.taskBesideEditor(t.Editor, t.Window)
case TaskStackWindows:
return nil, true, s.taskStackWindows(t.Windows, t.OffsetX, t.OffsetY)
case TaskApplyWorkflow:
return nil, true, s.taskApplyWorkflow(t.Workflow, t.Windows)
default:
return nil, false, nil
}
}
func (s *Service) taskOpenWindow(t TaskOpenWindow) (any, bool, error) {
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
}
x, y := pw.Position()
w, h := pw.Size()
info := WindowInfo{
Name: pw.Name(),
Title: pw.Title(),
X: x,
Y: y,
Width: w,
Height: h,
Visible: pw.IsVisible(),
Minimized: pw.IsMinimised(),
Maximized: pw.IsMaximised(),
Focused: pw.IsFocused(),
}
// Attach platform event listeners that convert to IPC actions
s.trackWindow(pw)
// Broadcast to all listeners
_ = s.Core().ACTION(ActionWindowOpened{Name: pw.Name()})
return info, true, nil
}
// trackWindow attaches platform event listeners that emit IPC actions.
func (s *Service) trackWindow(pw PlatformWindow) {
pw.OnWindowEvent(func(e WindowEvent) {
switch e.Type {
case "focus":
_ = s.Core().ACTION(ActionWindowFocused{Name: e.Name})
case "blur":
_ = s.Core().ACTION(ActionWindowBlurred{Name: e.Name})
case "move":
if data := e.Data; data != nil {
x, _ := data["x"].(int)
y, _ := data["y"].(int)
_ = s.Core().ACTION(ActionWindowMoved{Name: e.Name, X: x, Y: y})
}
case "resize":
if data := e.Data; data != nil {
w, _ := data["w"].(int)
h, _ := data["h"].(int)
_ = s.Core().ACTION(ActionWindowResized{Name: e.Name, Width: w, Height: h, W: w, H: h})
}
case "close":
_ = s.Core().ACTION(ActionWindowClosed{Name: e.Name})
}
})
pw.OnFileDrop(func(paths []string, targetID string) {
_ = s.Core().ACTION(ActionFilesDropped{
Name: pw.Name(),
Paths: paths,
TargetID: targetID,
})
})
}
func (s *Service) taskCloseWindow(name string) error {
pw, ok := s.manager.Get(name)
if !ok {
return corego.E("window.service", corego.Sprintf("window not found: %s", name), nil)
}
// Persist state BEFORE closing (spec requirement)
s.manager.State().CaptureState(pw)
pw.Close()
s.manager.Remove(name)
return nil
}
func (s *Service) taskSetPosition(name string, x, y int) error {
pw, ok := s.manager.Get(name)
if !ok {
return corego.E("window.service", corego.Sprintf("window not found: %s", name), nil)
}
pw.SetPosition(x, y)
s.manager.State().UpdatePosition(name, x, y)
return nil
}
func (s *Service) taskSetSize(name string, width, height, fallbackWidth, fallbackHeight int) error {
pw, ok := s.manager.Get(name)
if !ok {
return corego.E("window.service", corego.Sprintf("window not found: %s", name), nil)
}
if width == 0 && height == 0 {
width, height = fallbackWidth, fallbackHeight
} else {
if width == 0 {
width = fallbackWidth
}
if height == 0 {
height = fallbackHeight
}
}
pw.SetSize(width, height)
s.manager.State().UpdateSize(name, width, height)
return nil
}
func (s *Service) taskMaximise(name string) error {
pw, ok := s.manager.Get(name)
if !ok {
return corego.E("window.service", corego.Sprintf("window not found: %s", name), nil)
}
pw.Maximise()
s.manager.State().UpdateMaximized(name, true)
return nil
}
func (s *Service) taskMinimise(name string) error {
pw, ok := s.manager.Get(name)
if !ok {
return corego.E("window.service", corego.Sprintf("window not found: %s", name), nil)
}
pw.Minimise()
return nil
}
func (s *Service) taskFocus(name string) error {
pw, ok := s.manager.Get(name)
if !ok {
return corego.E("window.service", corego.Sprintf("window not found: %s", name), nil)
}
pw.Focus()
return nil
}
func (s *Service) taskRestore(name string) error {
pw, ok := s.manager.Get(name)
if !ok {
return corego.E("window.service", corego.Sprintf("window not found: %s", name), nil)
}
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 corego.E("window.service", corego.Sprintf("window not found: %s", name), nil)
}
pw.SetTitle(title)
return nil
}
func (s *Service) taskSetAlwaysOnTop(name string, alwaysOnTop bool) error {
pw, ok := s.manager.Get(name)
if !ok {
return corego.E("window.service", corego.Sprintf("window not found: %s", name), nil)
}
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 corego.E("window.service", corego.Sprintf("window not found: %s", name), nil)
}
pw.SetBackgroundColour(red, green, blue, alpha)
return nil
}
func (s *Service) taskSetOpacity(name string, opacity float32) error {
if opacity < 0 || opacity > 1 {
return corego.E("window.setOpacity", "opacity must be between 0 and 1", nil)
}
pw, ok := s.manager.Get(name)
if !ok {
return corego.E("window.service", corego.Sprintf("window not found: %s", name), nil)
}
pw.SetOpacity(opacity)
return nil
}
func (s *Service) taskSetVisibility(name string, visible bool) error {
pw, ok := s.manager.Get(name)
if !ok {
return corego.E("window.service", corego.Sprintf("window not found: %s", name), nil)
}
pw.SetVisibility(visible)
return nil
}
func (s *Service) taskFullscreen(name string, fullscreen bool) error {
pw, ok := s.manager.Get(name)
if !ok {
return corego.E("window.service", corego.Sprintf("window not found: %s", name), nil)
}
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 corego.E("window.restoreLayout", corego.Sprintf("layout not found: %s", name), nil)
}
for winName, state := range layout.Windows {
pw, found := s.manager.Get(winName)
if !found {
continue
}
if pw.IsMaximised() || pw.IsMinimised() {
pw.Restore()
}
pw.SetPosition(state.X, state.Y)
pw.SetSize(state.Width, state.Height)
if state.Maximized {
pw.Maximise()
} else {
pw.Restore()
}
}
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 corego.E("window.tileWindows", corego.Sprintf("unknown tile mode: %s", mode), nil)
}
if len(names) == 0 {
names = s.manager.List()
}
screenW, screenH := s.primaryScreenSize()
return s.manager.TileWindows(tm, names, screenW, screenH)
}
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 corego.E("window.snapWindow", corego.Sprintf("unknown snap position: %s", position), nil)
}
screenW, screenH := s.primaryScreenSize()
return s.manager.SnapWindow(name, pos, screenW, screenH)
}
func (s *Service) taskArrangePair(first, second string) error {
screenW, screenH := s.primaryScreenSize()
return s.manager.ArrangePair(first, second, screenW, screenH)
}
func (s *Service) taskBesideEditor(editorName, windowName string) error {
screenW, screenH := s.primaryScreenSize()
if editorName == "" {
editorName = s.detectEditorWindow()
}
if editorName == "" {
return corego.E("window.besideEditor", "editor window not found", nil)
}
if windowName == "" {
windowName = s.detectCompanionWindow(editorName)
}
if windowName == "" {
return corego.E("window.besideEditor", "companion window not found", nil)
}
return s.manager.BesideEditor(editorName, windowName, screenW, screenH)
}
func (s *Service) taskStackWindows(names []string, offsetX, offsetY int) error {
if len(names) == 0 {
names = s.manager.List()
}
return s.manager.StackWindows(names, offsetX, offsetY)
}
func (s *Service) taskApplyWorkflow(workflow WorkflowLayout, names []string) error {
screenW, screenH := s.primaryScreenSize()
if len(names) == 0 {
names = s.manager.List()
}
return s.manager.ApplyWorkflow(workflow, names, screenW, screenH)
}
func (s *Service) detectEditorWindow() string {
for _, info := range s.queryWindowList() {
if looksLikeEditor(info.Name, info.Title) {
return info.Name
}
}
return ""
}
func (s *Service) detectCompanionWindow(editorName string) string {
for _, info := range s.queryWindowList() {
if info.Name == editorName {
continue
}
if !looksLikeEditor(info.Name, info.Title) {
return info.Name
}
}
return ""
}
func looksLikeEditor(name, title string) bool {
return containsAny(name, "editor", "ide", "code", "workspace") || containsAny(title, "editor", "ide", "code")
}
func containsAny(value string, needles ...string) bool {
lower := corego.Lower(value)
for _, needle := range needles {
if corego.Contains(lower, needle) {
return true
}
}
return false
}
func (s *Service) primaryScreenSize() (int, int) {
result, handled, err := s.Core().QUERY(screen.QueryPrimary{})
if err == nil && handled {
if scr, ok := result.(*screen.Screen); ok && scr != nil {
if scr.WorkArea.Width > 0 && scr.WorkArea.Height > 0 {
return scr.WorkArea.Width, scr.WorkArea.Height
}
if scr.Bounds.Width > 0 && scr.Bounds.Height > 0 {
return scr.Bounds.Width, scr.Bounds.Height
}
if scr.Size.Width > 0 && scr.Size.Height > 0 {
return scr.Size.Width, scr.Size.Height
}
}
}
return 1920, 1080
}
// Manager returns the underlying window Manager for direct access.
// Use: mgr := svc.Manager()
func (s *Service) Manager() *Manager {
return s.manager
}