feat(window): restore config and screen-aware layouts

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Virgil 2026-04-02 13:09:54 +00:00
parent 5653bfcc8d
commit a1fbcdf6ed
7 changed files with 194 additions and 24 deletions

View file

@ -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.

View file

@ -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")

View file

@ -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)
}
}
}

View file

@ -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{

View file

@ -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)
}

View file

@ -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 = "/"

View file

@ -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.