feat(window): restore config and screen-aware layouts
Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
5653bfcc8d
commit
a1fbcdf6ed
7 changed files with 194 additions and 24 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = "/"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue