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>
587 lines
16 KiB
Go
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
|
|
}
|