diff --git a/pkg/display/display.go b/pkg/display/display.go index 9b6b77a..58656eb 100644 --- a/pkg/display/display.go +++ b/pkg/display/display.go @@ -7,9 +7,9 @@ import ( "path/filepath" "runtime" + "encoding/json" "forge.lthn.ai/core/config" "forge.lthn.ai/core/go/pkg/core" - "encoding/json" "forge.lthn.ai/core/gui/pkg/browser" "forge.lthn.ai/core/gui/pkg/contextmenu" @@ -43,7 +43,7 @@ type Service struct { config Options configData map[string]map[string]any cfg *config.Config // config instance for file persistence - events *WSEventManager + events *WSEventManager } // New is the constructor for the display service. @@ -917,7 +917,8 @@ func (s *Service) TileWindows(mode window.TileMode, windowNames []string) error if ws == nil { return fmt.Errorf("window service not available") } - return ws.Manager().TileWindows(mode, windowNames, 1920, 1080) // TODO: use actual screen size + screenWidth, screenHeight := s.primaryScreenSize() + return ws.Manager().TileWindows(mode, windowNames, screenWidth, screenHeight) } // SnapWindow snaps a window to a screen edge or corner. @@ -926,7 +927,35 @@ func (s *Service) SnapWindow(name string, position window.SnapPosition) error { if ws == nil { return fmt.Errorf("window service not available") } - return ws.Manager().SnapWindow(name, position, 1920, 1080) // TODO: use actual screen size + screenWidth, screenHeight := s.primaryScreenSize() + return ws.Manager().SnapWindow(name, position, screenWidth, screenHeight) +} + +func (s *Service) primaryScreenSize() (int, int) { + const fallbackWidth = 1920 + const fallbackHeight = 1080 + + result, handled, err := s.Core().QUERY(screen.QueryPrimary{}) + if err != nil || !handled { + return fallbackWidth, fallbackHeight + } + + primary, ok := result.(*screen.Screen) + if !ok || primary == nil { + return fallbackWidth, fallbackHeight + } + + width := primary.WorkArea.Width + height := primary.WorkArea.Height + if width <= 0 || height <= 0 { + width = primary.Bounds.Width + height = primary.Bounds.Height + } + if width <= 0 || height <= 0 { + return fallbackWidth, fallbackHeight + } + + return width, height } // StackWindows arranges windows in a cascade pattern. @@ -944,7 +973,8 @@ func (s *Service) ApplyWorkflowLayout(workflow window.WorkflowLayout) error { if ws == nil { return fmt.Errorf("window service not available") } - return ws.Manager().ApplyWorkflow(workflow, ws.Manager().List(), 1920, 1080) + screenWidth, screenHeight := s.primaryScreenSize() + return ws.Manager().ApplyWorkflow(workflow, ws.Manager().List(), screenWidth, screenHeight) } // GetEventManager returns the event manager for WebSocket event subscriptions. diff --git a/pkg/display/display_test.go b/pkg/display/display_test.go index 03eb1d2..4d85897 100644 --- a/pkg/display/display_test.go +++ b/pkg/display/display_test.go @@ -8,12 +8,28 @@ import ( "forge.lthn.ai/core/go/pkg/core" "forge.lthn.ai/core/gui/pkg/menu" + "forge.lthn.ai/core/gui/pkg/screen" "forge.lthn.ai/core/gui/pkg/systray" "forge.lthn.ai/core/gui/pkg/window" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +type mockScreenPlatform struct { + screens []screen.Screen +} + +func (m *mockScreenPlatform) GetAll() []screen.Screen { return m.screens } + +func (m *mockScreenPlatform) GetPrimary() *screen.Screen { + for i := range m.screens { + if m.screens[i].IsPrimary { + return &m.screens[i] + } + } + return nil +} + // --- Test helpers --- // newTestDisplayService creates a display service registered with Core for IPC testing. @@ -35,6 +51,14 @@ func newTestConclave(t *testing.T) *core.Core { c, err := core.New( core.WithService(Register(nil)), core.WithService(window.Register(window.NewMockPlatform())), + core.WithService(screen.Register(&mockScreenPlatform{ + screens: []screen.Screen{{ + ID: "primary", Name: "Primary", IsPrimary: true, + Size: screen.Size{Width: 2560, Height: 1440}, + Bounds: screen.Rect{X: 0, Y: 0, Width: 2560, Height: 1440}, + WorkArea: screen.Rect{X: 0, Y: 0, Width: 2560, Height: 1440}, + }}, + })), core.WithService(systray.Register(systray.NewMockPlatform())), core.WithService(menu.Register(menu.NewMockPlatform())), core.WithServiceLock(), @@ -222,6 +246,25 @@ func TestGetWindowInfo_Bad(t *testing.T) { assert.Nil(t, info) } +func TestTileWindows_UsesPrimaryScreenSize(t *testing.T) { + c := newTestConclave(t) + svc := core.MustServiceFor[*Service](c, "display") + + _ = svc.OpenWindow(window.WithName("left")) + _ = svc.OpenWindow(window.WithName("right")) + + err := svc.TileWindows(window.TileModeLeftRight, []string{"left", "right"}) + require.NoError(t, err) + + left, err := svc.GetWindowInfo("left") + require.NoError(t, err) + assert.Equal(t, 1280, left.Width) + + right, err := svc.GetWindowInfo("right") + require.NoError(t, err) + assert.Equal(t, 1280, right.Width) +} + func TestListWindowInfos_Good(t *testing.T) { c := newTestConclave(t) svc := core.MustServiceFor[*Service](c, "display") diff --git a/pkg/window/service.go b/pkg/window/service.go index 06ea1f6..a1701d5 100644 --- a/pkg/window/service.go +++ b/pkg/window/service.go @@ -40,19 +40,19 @@ func (s *Service) OnStartup(ctx context.Context) error { } func (s *Service) applyConfig(cfg map[string]any) { - if w, ok := cfg["default_width"]; ok { - if _, ok := w.(int); ok { - // TODO: s.manager.SetDefaultWidth(width) — add when Manager API is extended + if width, ok := cfg["default_width"]; ok { + if width, ok := width.(int); ok { + s.manager.SetDefaultWidth(width) } } - if h, ok := cfg["default_height"]; ok { - if _, ok := h.(int); ok { - // TODO: s.manager.SetDefaultHeight(height) — add when Manager API is extended + if height, ok := cfg["default_height"]; ok { + if height, ok := height.(int); ok { + s.manager.SetDefaultHeight(height) } } - if sf, ok := cfg["state_file"]; ok { - if _, ok := sf.(string); ok { - // TODO: s.manager.State().SetPath(stateFile) — add when StateManager API is extended + if stateFile, ok := cfg["state_file"]; ok { + if stateFile, ok := stateFile.(string); ok { + s.manager.State().SetPath(stateFile) } } } diff --git a/pkg/window/service_test.go b/pkg/window/service_test.go index 1911044..0752c0d 100644 --- a/pkg/window/service_test.go +++ b/pkg/window/service_test.go @@ -28,6 +28,21 @@ func TestRegister_Good(t *testing.T) { assert.NotNil(t, svc.manager) } +func TestApplyConfig_Good(t *testing.T) { + svc, _ := newTestWindowService(t) + + svc.applyConfig(map[string]any{ + "default_width": 1500, + "default_height": 900, + }) + + pw, err := svc.manager.Open() + require.NoError(t, err) + w, h := pw.Size() + assert.Equal(t, 1500, w) + assert.Equal(t, 900, h) +} + func TestTaskOpenWindow_Good(t *testing.T) { _, c := newTestWindowService(t) result, handled, err := c.PERFORM(TaskOpenWindow{ diff --git a/pkg/window/state.go b/pkg/window/state.go index 3523cfe..91c6c81 100644 --- a/pkg/window/state.go +++ b/pkg/window/state.go @@ -25,6 +25,7 @@ type WindowState struct { // StateManager persists window positions to ~/.config/Core/window_state.json. type StateManager struct { configDir string + statePath string states map[string]WindowState mu sync.RWMutex saveTimer *time.Timer @@ -55,11 +56,37 @@ func NewStateManagerWithDir(configDir string) *StateManager { } func (sm *StateManager) filePath() string { + if sm.statePath != "" { + return sm.statePath + } return filepath.Join(sm.configDir, "window_state.json") } +func (sm *StateManager) dataDir() string { + if sm.statePath != "" { + return filepath.Dir(sm.statePath) + } + return sm.configDir +} + +// SetPath overrides the persisted state file path. +func (sm *StateManager) SetPath(path string) { + if path == "" { + return + } + sm.mu.Lock() + if sm.saveTimer != nil { + sm.saveTimer.Stop() + sm.saveTimer = nil + } + sm.statePath = path + sm.states = make(map[string]WindowState) + sm.mu.Unlock() + sm.load() +} + func (sm *StateManager) load() { - if sm.configDir == "" { + if sm.configDir == "" && sm.statePath == "" { return } data, err := os.ReadFile(sm.filePath()) @@ -72,7 +99,7 @@ func (sm *StateManager) load() { } func (sm *StateManager) save() { - if sm.configDir == "" { + if sm.configDir == "" && sm.statePath == "" { return } sm.mu.RLock() @@ -81,7 +108,7 @@ func (sm *StateManager) save() { if err != nil { return } - _ = os.MkdirAll(sm.configDir, 0o755) + _ = os.MkdirAll(sm.dataDir(), 0o755) _ = os.WriteFile(sm.filePath(), data, 0o644) } diff --git a/pkg/window/window.go b/pkg/window/window.go index 9fcbd98..cfdb0db 100644 --- a/pkg/window/window.go +++ b/pkg/window/window.go @@ -39,11 +39,13 @@ func (w *Window) ToPlatformOptions() PlatformWindowOptions { // Manager manages window lifecycle through a Platform backend. type Manager struct { - platform Platform - state *StateManager - layout *LayoutManager - windows map[string]PlatformWindow - mu sync.RWMutex + platform Platform + state *StateManager + layout *LayoutManager + windows map[string]PlatformWindow + defaultWidth int + defaultHeight int + mu sync.RWMutex } // NewManager creates a window Manager with the given platform backend. @@ -67,6 +69,20 @@ func NewManagerWithDir(platform Platform, configDir string) *Manager { } } +// SetDefaultWidth overrides the fallback width used when a window is created without one. +func (m *Manager) SetDefaultWidth(width int) { + if width > 0 { + m.defaultWidth = width + } +} + +// SetDefaultHeight overrides the fallback height used when a window is created without one. +func (m *Manager) SetDefaultHeight(height int) { + if height > 0 { + m.defaultHeight = height + } +} + // Open creates a window using functional options, applies saved state, and tracks it. func (m *Manager) Open(opts ...WindowOption) (PlatformWindow, error) { w, err := ApplyOptions(opts...) @@ -85,10 +101,18 @@ func (m *Manager) Create(w *Window) (PlatformWindow, error) { w.Title = "Core" } if w.Width == 0 { - w.Width = 1280 + if m.defaultWidth > 0 { + w.Width = m.defaultWidth + } else { + w.Width = 1280 + } } if w.Height == 0 { - w.Height = 800 + if m.defaultHeight > 0 { + w.Height = m.defaultHeight + } else { + w.Height = 800 + } } if w.URL == "" { w.URL = "/" diff --git a/pkg/window/window_test.go b/pkg/window/window_test.go index 9a2c2c2..f0b5d73 100644 --- a/pkg/window/window_test.go +++ b/pkg/window/window_test.go @@ -2,6 +2,7 @@ package window import ( + "path/filepath" "testing" "github.com/stretchr/testify/assert" @@ -110,6 +111,19 @@ func TestManager_Open_Defaults_Good(t *testing.T) { assert.Equal(t, 800, h) } +func TestManager_DefaultSizeOverrides_Good(t *testing.T) { + m, _ := newTestManager() + m.SetDefaultWidth(1440) + m.SetDefaultHeight(900) + + pw, err := m.Open() + require.NoError(t, err) + + w, h := pw.Size() + assert.Equal(t, 1440, w) + assert.Equal(t, 900, h) +} + func TestManager_Open_Bad(t *testing.T) { m, _ := newTestManager() _, err := m.Open(func(w *Window) error { return assert.AnError }) @@ -226,6 +240,23 @@ func TestStateManager_Persistence_Good(t *testing.T) { assert.Equal(t, 500, got.Width) } +func TestStateManager_SetPath_Good(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "custom-window-state.json") + sm := &StateManager{states: make(map[string]WindowState)} + + sm.SetPath(path) + sm.SetState("custom", WindowState{X: 11, Y: 22, Width: 333, Height: 444}) + sm.ForceSync() + + reloaded := &StateManager{states: make(map[string]WindowState)} + reloaded.SetPath(path) + got, ok := reloaded.GetState("custom") + require.True(t, ok) + assert.Equal(t, 11, got.X) + assert.Equal(t, 333, got.Width) +} + // --- LayoutManager Tests --- // newTestLayoutManager creates a clean LayoutManager with a temp dir for testing.