gui/pkg/display/display.go
Snider 4814f960fb refactor(display): compose window/systray/menu sub-packages into orchestrator
Service now delegates to window.Manager, systray.Manager, and menu.Manager
instead of directly using Wails types. WSEventManager accepts EventSource
interface instead of calling application.Get() directly.
AttachWindowListeners now accepts window.PlatformWindow.

Removes migrated files: window.go, window_state.go, layout.go, tray.go, menu.go.
Tests rewritten against mock platform implementations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 12:27:19 +00:00

749 lines
21 KiB
Go

package display
import (
"context"
"fmt"
"runtime"
"forge.lthn.ai/core/go/pkg/core"
"forge.lthn.ai/core/gui/pkg/menu"
"forge.lthn.ai/core/gui/pkg/systray"
"forge.lthn.ai/core/gui/pkg/window"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/services/notifications"
)
// Options holds configuration for the display service.
type Options struct{}
// Service manages windowing, dialogs, and other visual elements.
// It composes window.Manager, systray.Manager, and menu.Manager.
type Service struct {
*core.ServiceRuntime[Options]
app App
config Options
windows *window.Manager
tray *systray.Manager
menus *menu.Manager
notifier *notifications.NotificationService
events *WSEventManager
}
// newDisplayService contains the common logic for initializing a Service struct.
func newDisplayService() (*Service, error) {
return &Service{}, nil
}
// New is the constructor for the display service.
func New() (*Service, error) {
s, err := newDisplayService()
if err != nil {
return nil, err
}
return s, nil
}
// Register creates and registers a new display service with the given Core instance.
func Register(c *core.Core) (any, error) {
s, err := New()
if err != nil {
return nil, err
}
s.ServiceRuntime = core.NewServiceRuntime[Options](c, Options{})
return s, nil
}
// ServiceName returns the canonical name for this service.
func (s *Service) ServiceName() string {
return "forge.lthn.ai/core/gui/display"
}
// ServiceStartup is called by Wails when the app starts.
func (s *Service) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
return s.Startup(ctx)
}
// Startup initialises the display service and sets up sub-managers.
func (s *Service) Startup(ctx context.Context) error {
wailsApp := application.Get()
s.app = newWailsApp(wailsApp)
// Create sub-manager platform adapters
s.windows = window.NewManager(window.NewWailsPlatform(wailsApp))
s.tray = systray.NewManager(systray.NewWailsPlatform(wailsApp))
s.menus = menu.NewManager(menu.NewWailsPlatform(wailsApp))
s.events = NewWSEventManager(newWailsEventSource(wailsApp))
s.events.SetupWindowEventListeners()
s.app.Logger().Info("Display service started")
s.buildMenu()
s.setupTray()
return s.OpenWindow()
}
// --- Window Management (delegates to window.Manager) ---
// OpenWindow creates a new window with the given options.
func (s *Service) OpenWindow(opts ...window.WindowOption) error {
pw, err := s.windows.Open(opts...)
if err != nil {
return err
}
s.trackWindow(pw)
return nil
}
// trackWindow attaches event listeners for state persistence and WebSocket events.
func (s *Service) trackWindow(pw window.PlatformWindow) {
if s.events != nil {
s.events.EmitWindowEvent(EventWindowCreate, pw.Name(), map[string]any{
"name": pw.Name(),
})
s.events.AttachWindowListeners(pw)
}
}
// GetWindowInfo returns information about a window by name.
func (s *Service) GetWindowInfo(name string) (*WindowInfo, error) {
pw, ok := s.windows.Get(name)
if !ok {
return nil, fmt.Errorf("window not found: %s", name)
}
x, y := pw.Position()
w, h := pw.Size()
return &WindowInfo{
Name: name,
X: x,
Y: y,
Width: w,
Height: h,
Maximized: pw.IsMaximised(),
}, nil
}
// ListWindowInfos returns information about all tracked windows.
func (s *Service) ListWindowInfos() []WindowInfo {
names := s.windows.List()
result := make([]WindowInfo, 0, len(names))
for _, name := range names {
if pw, ok := s.windows.Get(name); ok {
x, y := pw.Position()
w, h := pw.Size()
result = append(result, WindowInfo{
Name: name,
X: x,
Y: y,
Width: w,
Height: h,
Maximized: pw.IsMaximised(),
})
}
}
return result
}
// SetWindowPosition moves a window to the specified position.
func (s *Service) SetWindowPosition(name string, x, y int) error {
pw, ok := s.windows.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
}
pw.SetPosition(x, y)
s.windows.State().UpdatePosition(name, x, y)
return nil
}
// SetWindowSize resizes a window.
func (s *Service) SetWindowSize(name string, width, height int) error {
pw, ok := s.windows.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
}
pw.SetSize(width, height)
s.windows.State().UpdateSize(name, width, height)
return nil
}
// SetWindowBounds sets both position and size of a window.
func (s *Service) SetWindowBounds(name string, x, y, width, height int) error {
pw, ok := s.windows.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
}
pw.SetPosition(x, y)
pw.SetSize(width, height)
return nil
}
// MaximizeWindow maximizes a window.
func (s *Service) MaximizeWindow(name string) error {
pw, ok := s.windows.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
}
pw.Maximise()
s.windows.State().UpdateMaximized(name, true)
return nil
}
// RestoreWindow restores a maximized/minimized window.
func (s *Service) RestoreWindow(name string) error {
pw, ok := s.windows.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
}
pw.Restore()
return nil
}
// MinimizeWindow minimizes a window.
func (s *Service) MinimizeWindow(name string) error {
pw, ok := s.windows.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
}
pw.Minimise()
return nil
}
// FocusWindow brings a window to the front.
func (s *Service) FocusWindow(name string) error {
pw, ok := s.windows.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
}
pw.Focus()
return nil
}
// CloseWindow closes a window by name.
func (s *Service) CloseWindow(name string) error {
pw, ok := s.windows.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
}
s.windows.State().CaptureState(pw)
pw.Close()
s.windows.Remove(name)
return nil
}
// SetWindowVisibility shows or hides a window.
func (s *Service) SetWindowVisibility(name string, visible bool) error {
pw, ok := s.windows.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
}
pw.SetVisibility(visible)
return nil
}
// SetWindowAlwaysOnTop sets whether a window stays on top.
func (s *Service) SetWindowAlwaysOnTop(name string, alwaysOnTop bool) error {
pw, ok := s.windows.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
}
pw.SetAlwaysOnTop(alwaysOnTop)
return nil
}
// SetWindowTitle changes a window's title.
func (s *Service) SetWindowTitle(name string, title string) error {
pw, ok := s.windows.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
}
pw.SetTitle(title)
return nil
}
// SetWindowFullscreen sets a window to fullscreen mode.
func (s *Service) SetWindowFullscreen(name string, fullscreen bool) error {
pw, ok := s.windows.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
}
if fullscreen {
pw.Fullscreen()
} else {
pw.UnFullscreen()
}
return nil
}
// SetWindowBackgroundColour sets the background colour of a window.
func (s *Service) SetWindowBackgroundColour(name string, r, g, b, a uint8) error {
pw, ok := s.windows.Get(name)
if !ok {
return fmt.Errorf("window not found: %s", name)
}
pw.SetBackgroundColour(r, g, b, a)
return nil
}
// GetFocusedWindow returns the name of the currently focused window.
func (s *Service) GetFocusedWindow() string {
for _, name := range s.windows.List() {
if pw, ok := s.windows.Get(name); ok {
if pw.IsFocused() {
return name
}
}
}
return ""
}
// GetWindowTitle returns the title of a window by name.
func (s *Service) GetWindowTitle(name string) (string, error) {
_, ok := s.windows.Get(name)
if !ok {
return "", fmt.Errorf("window not found: %s", name)
}
return name, nil // Wails v3 doesn't expose a title getter
}
// ResetWindowState clears saved window positions.
func (s *Service) ResetWindowState() error {
if s.windows != nil {
s.windows.State().Clear()
}
return nil
}
// GetSavedWindowStates returns all saved window states.
func (s *Service) GetSavedWindowStates() map[string]window.WindowState {
if s.windows == nil {
return nil
}
result := make(map[string]window.WindowState)
for _, name := range s.windows.State().ListStates() {
if state, ok := s.windows.State().GetState(name); ok {
result[name] = state
}
}
return result
}
// WindowInfo contains information about a window for MCP.
type WindowInfo struct {
Name string `json:"name"`
X int `json:"x"`
Y int `json:"y"`
Width int `json:"width"`
Height int `json:"height"`
Maximized bool `json:"maximized"`
}
// 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"`
}
// CreateWindow creates a new window with the specified options.
func (s *Service) CreateWindow(opts CreateWindowOptions) (*WindowInfo, error) {
if opts.Name == "" {
return nil, fmt.Errorf("window name is required")
}
err := s.OpenWindow(
window.WithName(opts.Name),
window.WithTitle(opts.Title),
window.WithURL(opts.URL),
window.WithSize(opts.Width, opts.Height),
window.WithPosition(opts.X, opts.Y),
)
if err != nil {
return nil, err
}
return &WindowInfo{
Name: opts.Name,
X: opts.X,
Y: opts.Y,
Width: opts.Width,
Height: opts.Height,
}, nil
}
// --- Layout delegation ---
// SaveLayout saves the current window arrangement as a named layout.
func (s *Service) SaveLayout(name string) error {
if s.windows == nil {
return fmt.Errorf("window manager not initialized")
}
states := make(map[string]window.WindowState)
for _, n := range s.windows.List() {
if pw, ok := s.windows.Get(n); ok {
x, y := pw.Position()
w, h := pw.Size()
states[n] = window.WindowState{X: x, Y: y, Width: w, Height: h, Maximized: pw.IsMaximised()}
}
}
return s.windows.Layout().SaveLayout(name, states)
}
// RestoreLayout applies a saved layout.
func (s *Service) RestoreLayout(name string) error {
if s.windows == nil {
return fmt.Errorf("window manager not initialized")
}
layout, ok := s.windows.Layout().GetLayout(name)
if !ok {
return fmt.Errorf("layout not found: %s", name)
}
for wName, state := range layout.Windows {
if pw, ok := s.windows.Get(wName); ok {
pw.SetPosition(state.X, state.Y)
pw.SetSize(state.Width, state.Height)
if state.Maximized {
pw.Maximise()
} else {
pw.Restore()
}
}
}
return nil
}
// ListLayouts returns all saved layout names with metadata.
func (s *Service) ListLayouts() []window.LayoutInfo {
if s.windows == nil {
return nil
}
return s.windows.Layout().ListLayouts()
}
// DeleteLayout removes a saved layout by name.
func (s *Service) DeleteLayout(name string) error {
if s.windows == nil {
return fmt.Errorf("window manager not initialized")
}
s.windows.Layout().DeleteLayout(name)
return nil
}
// GetLayout returns a specific layout by name.
func (s *Service) GetLayout(name string) *window.Layout {
if s.windows == nil {
return nil
}
layout, ok := s.windows.Layout().GetLayout(name)
if !ok {
return nil
}
return &layout
}
// --- Tiling/snapping delegation ---
// TileWindows arranges windows in a tiled layout.
func (s *Service) TileWindows(mode window.TileMode, windowNames []string) error {
return s.windows.TileWindows(mode, windowNames, 1920, 1080) // TODO: use actual screen size
}
// SnapWindow snaps a window to a screen edge or corner.
func (s *Service) SnapWindow(name string, position window.SnapPosition) error {
return s.windows.SnapWindow(name, position, 1920, 1080) // TODO: use actual screen size
}
// StackWindows arranges windows in a cascade pattern.
func (s *Service) StackWindows(windowNames []string, offsetX, offsetY int) error {
return s.windows.StackWindows(windowNames, offsetX, offsetY)
}
// ApplyWorkflowLayout applies a predefined layout for a specific workflow.
func (s *Service) ApplyWorkflowLayout(workflow window.WorkflowLayout) error {
return s.windows.ApplyWorkflow(workflow, s.windows.List(), 1920, 1080)
}
// --- Screen queries (remain in display — use application.Get() directly) ---
// ScreenInfo contains information about a display screen.
type ScreenInfo struct {
ID string `json:"id"`
Name string `json:"name"`
X int `json:"x"`
Y int `json:"y"`
Width int `json:"width"`
Height int `json:"height"`
Primary bool `json:"primary"`
}
// WorkArea represents usable screen space.
type WorkArea struct {
ScreenID string `json:"screenId"`
X int `json:"x"`
Y int `json:"y"`
Width int `json:"width"`
Height int `json:"height"`
}
// GetScreens returns information about all available screens.
func (s *Service) GetScreens() []ScreenInfo {
app := application.Get()
if app == nil || app.Screen == nil {
return nil
}
screens := app.Screen.GetAll()
if screens == nil {
return nil
}
result := make([]ScreenInfo, 0, len(screens))
for _, screen := range screens {
result = append(result, ScreenInfo{
ID: screen.ID,
Name: screen.Name,
X: screen.Bounds.X,
Y: screen.Bounds.Y,
Width: screen.Bounds.Width,
Height: screen.Bounds.Height,
Primary: screen.IsPrimary,
})
}
return result
}
// GetWorkAreas returns the usable work area for all screens.
func (s *Service) GetWorkAreas() []WorkArea {
app := application.Get()
if app == nil || app.Screen == nil {
return nil
}
screens := app.Screen.GetAll()
if screens == nil {
return nil
}
result := make([]WorkArea, 0, len(screens))
for _, screen := range screens {
result = append(result, WorkArea{
ScreenID: screen.ID,
X: screen.WorkArea.X,
Y: screen.WorkArea.Y,
Width: screen.WorkArea.Width,
Height: screen.WorkArea.Height,
})
}
return result
}
// GetPrimaryScreen returns information about the primary screen.
func (s *Service) GetPrimaryScreen() (*ScreenInfo, error) {
app := application.Get()
if app == nil || app.Screen == nil {
return nil, fmt.Errorf("screen service not available")
}
screens := app.Screen.GetAll()
for _, screen := range screens {
if screen.IsPrimary {
return &ScreenInfo{
ID: screen.ID, Name: screen.Name,
X: screen.Bounds.X, Y: screen.Bounds.Y,
Width: screen.Bounds.Width, Height: screen.Bounds.Height,
Primary: true,
}, nil
}
}
return nil, fmt.Errorf("no primary screen found")
}
// GetScreen returns information about a specific screen by ID.
func (s *Service) GetScreen(id string) (*ScreenInfo, error) {
app := application.Get()
if app == nil || app.Screen == nil {
return nil, fmt.Errorf("screen service not available")
}
screens := app.Screen.GetAll()
for _, screen := range screens {
if screen.ID == id {
return &ScreenInfo{
ID: screen.ID, Name: screen.Name,
X: screen.Bounds.X, Y: screen.Bounds.Y,
Width: screen.Bounds.Width, Height: screen.Bounds.Height,
Primary: screen.IsPrimary,
}, nil
}
}
return nil, fmt.Errorf("screen not found: %s", id)
}
// GetScreenAtPoint returns the screen containing a specific point.
func (s *Service) GetScreenAtPoint(x, y int) (*ScreenInfo, error) {
app := application.Get()
if app == nil || app.Screen == nil {
return nil, fmt.Errorf("screen service not available")
}
screens := app.Screen.GetAll()
for _, screen := range screens {
bounds := screen.Bounds
if x >= bounds.X && x < bounds.X+bounds.Width &&
y >= bounds.Y && y < bounds.Y+bounds.Height {
return &ScreenInfo{
ID: screen.ID, Name: screen.Name,
X: bounds.X, Y: bounds.Y,
Width: bounds.Width, Height: bounds.Height,
Primary: screen.IsPrimary,
}, nil
}
}
return nil, fmt.Errorf("no screen found at point (%d, %d)", x, y)
}
// GetScreenForWindow returns the screen containing a specific window.
func (s *Service) GetScreenForWindow(name string) (*ScreenInfo, error) {
info, err := s.GetWindowInfo(name)
if err != nil {
return nil, err
}
centerX := info.X + info.Width/2
centerY := info.Y + info.Height/2
return s.GetScreenAtPoint(centerX, centerY)
}
// ShowEnvironmentDialog displays environment information.
func (s *Service) ShowEnvironmentDialog() {
envInfo := s.app.Env().Info()
details := "Environment Information:\n\n"
details += fmt.Sprintf("Operating System: %s\n", envInfo.OS)
details += fmt.Sprintf("Architecture: %s\n", envInfo.Arch)
details += fmt.Sprintf("Debug Mode: %t\n\n", envInfo.Debug)
details += fmt.Sprintf("Dark Mode: %t\n\n", s.app.Env().IsDarkMode())
details += "Platform Information:"
for key, value := range envInfo.PlatformInfo {
details += fmt.Sprintf("\n%s: %v", key, value)
}
if envInfo.OSInfo != nil {
details += fmt.Sprintf("\n\nOS Details:\nName: %s\nVersion: %s",
envInfo.OSInfo.Name, envInfo.OSInfo.Version)
}
dialog := s.app.Dialog().Info()
dialog.SetTitle("Environment Information")
dialog.SetMessage(details)
dialog.Show()
}
// GetEventManager returns the event manager for WebSocket event subscriptions.
func (s *Service) GetEventManager() *WSEventManager {
return s.events
}
// --- Menu (handlers stay in display, structure delegated to menu.Manager) ---
func (s *Service) buildMenu() {
items := []menu.MenuItem{
{Role: ptr(menu.RoleAppMenu)},
{Role: ptr(menu.RoleFileMenu)},
{Role: ptr(menu.RoleViewMenu)},
{Role: ptr(menu.RoleEditMenu)},
{Label: "Workspace", Children: []menu.MenuItem{
{Label: "New...", OnClick: s.handleNewWorkspace},
{Label: "List", OnClick: s.handleListWorkspaces},
}},
{Label: "Developer", Children: []menu.MenuItem{
{Label: "New File", Accelerator: "CmdOrCtrl+N", OnClick: s.handleNewFile},
{Label: "Open File...", Accelerator: "CmdOrCtrl+O", OnClick: s.handleOpenFile},
{Label: "Save", Accelerator: "CmdOrCtrl+S", OnClick: s.handleSaveFile},
{Type: "separator"},
{Label: "Editor", OnClick: s.handleOpenEditor},
{Label: "Terminal", OnClick: s.handleOpenTerminal},
{Type: "separator"},
{Label: "Run", Accelerator: "CmdOrCtrl+R", OnClick: s.handleRun},
{Label: "Build", Accelerator: "CmdOrCtrl+B", OnClick: s.handleBuild},
}},
{Role: ptr(menu.RoleWindowMenu)},
{Role: ptr(menu.RoleHelpMenu)},
}
// On non-macOS, remove the AppMenu role
if runtime.GOOS != "darwin" {
items = items[1:] // skip AppMenu
}
s.menus.SetApplicationMenu(items)
}
func ptr[T any](v T) *T { return &v }
// --- Menu handler methods ---
func (s *Service) handleNewWorkspace() {
_ = s.OpenWindow(window.WithName("workspace-new"), window.WithTitle("New Workspace"),
window.WithURL("/workspace/new"), window.WithSize(500, 400))
}
func (s *Service) handleListWorkspaces() {
ws := s.Core().Service("workspace")
if ws == nil {
return
}
lister, ok := ws.(interface{ ListWorkspaces() []string })
if !ok {
return
}
_ = lister.ListWorkspaces()
}
func (s *Service) handleNewFile() {
_ = s.OpenWindow(window.WithName("editor"), window.WithTitle("New File - Editor"),
window.WithURL("/#/developer/editor?new=true"), window.WithSize(1200, 800))
}
func (s *Service) handleOpenFile() {
dialog := s.app.Dialog().OpenFile()
dialog.SetTitle("Open File")
dialog.CanChooseFiles(true)
dialog.CanChooseDirectories(false)
result, err := dialog.PromptForSingleSelection()
if err != nil || result == "" {
return
}
_ = s.OpenWindow(window.WithName("editor"), window.WithTitle(result+" - Editor"),
window.WithURL("/#/developer/editor?file="+result), window.WithSize(1200, 800))
}
func (s *Service) handleSaveFile() { s.app.Event().Emit("ide:save") }
func (s *Service) handleOpenEditor() {
_ = s.OpenWindow(window.WithName("editor"), window.WithTitle("Editor"),
window.WithURL("/#/developer/editor"), window.WithSize(1200, 800))
}
func (s *Service) handleOpenTerminal() {
_ = s.OpenWindow(window.WithName("terminal"), window.WithTitle("Terminal"),
window.WithURL("/#/developer/terminal"), window.WithSize(800, 500))
}
func (s *Service) handleRun() { s.app.Event().Emit("ide:run") }
func (s *Service) handleBuild() { s.app.Event().Emit("ide:build") }
// --- Tray (setup delegated to systray.Manager) ---
func (s *Service) setupTray() {
_ = s.tray.Setup("Core", "Core")
s.tray.RegisterCallback("open-desktop", func() {
for _, name := range s.windows.List() {
if pw, ok := s.windows.Get(name); ok {
pw.Show()
}
}
})
s.tray.RegisterCallback("close-desktop", func() {
for _, name := range s.windows.List() {
if pw, ok := s.windows.Get(name); ok {
pw.Hide()
}
}
})
s.tray.RegisterCallback("env-info", func() { s.ShowEnvironmentDialog() })
s.tray.RegisterCallback("quit", func() { s.app.Quit() })
_ = s.tray.SetMenu([]systray.TrayMenuItem{
{Label: "Open Desktop", ActionID: "open-desktop"},
{Label: "Close Desktop", ActionID: "close-desktop"},
{Type: "separator"},
{Label: "Environment Info", ActionID: "env-info"},
{Type: "separator"},
{Label: "Quit", ActionID: "quit"},
})
}