feat(display): refactor to closure Register pattern, add config IPC handlers
Register(wailsApp) returns factory closure for WithService. OnStartup registers config query/task handlers synchronously. Handles window.QueryConfig, systray.QueryConfig, menu.QueryConfig. Remove ServiceName() — auto-derived as 'display' by WithService. Remove ServiceStartup/Startup — replaced by OnStartup (Startable interface). Remove actions.go — replaced by sub-package message types. Add EventTrayClick and EventTrayMenuItemClick event types. Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
parent
3afa0c84d3
commit
d29cf62e6f
4 changed files with 151 additions and 106 deletions
|
|
@ -1,9 +0,0 @@
|
|||
// pkg/display/actions.go
|
||||
package display
|
||||
|
||||
import "forge.lthn.ai/core/gui/pkg/window"
|
||||
|
||||
// ActionOpenWindow is an IPC message type requesting a new window.
|
||||
type ActionOpenWindow struct {
|
||||
window.Window
|
||||
}
|
||||
|
|
@ -17,68 +17,104 @@ import (
|
|||
type Options struct{}
|
||||
|
||||
// Service manages windowing, dialogs, and other visual elements.
|
||||
// It composes window.Manager, systray.Manager, and menu.Manager.
|
||||
// It orchestrates sub-services (window, systray, menu) via IPC and bridges
|
||||
// IPC actions to WebSocket events for TypeScript apps.
|
||||
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
|
||||
wailsApp *application.App
|
||||
app App
|
||||
config Options
|
||||
configData map[string]map[string]any
|
||||
windows *window.Manager
|
||||
tray *systray.Manager
|
||||
menus *menu.Manager
|
||||
notifier *notifications.NotificationService
|
||||
events *WSEventManager
|
||||
}
|
||||
|
||||
// New is the constructor for the display service.
|
||||
func New() (*Service, error) {
|
||||
s, err := newDisplayService()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return &Service{
|
||||
configData: map[string]map[string]any{
|
||||
"window": {},
|
||||
"systray": {},
|
||||
"menu": {},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Register creates a factory closure that captures the Wails app.
|
||||
// Pass nil for testing without a Wails runtime.
|
||||
func Register(wailsApp *application.App) func(*core.Core) (any, error) {
|
||||
return func(c *core.Core) (any, error) {
|
||||
s, err := New()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.ServiceRuntime = core.NewServiceRuntime[Options](c, Options{})
|
||||
s.wailsApp = wailsApp
|
||||
return s, nil
|
||||
}
|
||||
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
|
||||
// OnStartup loads config and registers IPC handlers synchronously.
|
||||
// CRITICAL: config handlers MUST be registered before returning —
|
||||
// sub-services depend on them during their own OnStartup.
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
s.loadConfig()
|
||||
|
||||
// Register config query/task handlers — available NOW for sub-services
|
||||
s.Core().RegisterQuery(s.handleConfigQuery)
|
||||
s.Core().RegisterTask(s.handleConfigTask)
|
||||
|
||||
// Initialise Wails wrappers if app is available (nil in tests)
|
||||
if s.wailsApp != nil {
|
||||
s.app = newWailsApp(s.wailsApp)
|
||||
s.events = NewWSEventManager(newWailsEventSource(s.wailsApp))
|
||||
s.events.SetupWindowEventListeners()
|
||||
}
|
||||
s.ServiceRuntime = core.NewServiceRuntime[Options](c, Options{})
|
||||
return s, nil
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ServiceName returns the canonical name for this service.
|
||||
func (s *Service) ServiceName() string {
|
||||
return "forge.lthn.ai/core/gui/display"
|
||||
func (s *Service) loadConfig() {
|
||||
// In-memory defaults. go-config integration is deferred work.
|
||||
if s.configData == nil {
|
||||
s.configData = map[string]map[string]any{
|
||||
"window": {},
|
||||
"systray": {},
|
||||
"menu": {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ServiceStartup is called by Wails when the app starts.
|
||||
func (s *Service) ServiceStartup(ctx context.Context, options application.ServiceOptions) error {
|
||||
return s.Startup(ctx)
|
||||
func (s *Service) handleConfigQuery(c *core.Core, q core.Query) (any, bool, error) {
|
||||
switch q.(type) {
|
||||
case window.QueryConfig:
|
||||
return s.configData["window"], true, nil
|
||||
case systray.QueryConfig:
|
||||
return s.configData["systray"], true, nil
|
||||
case menu.QueryConfig:
|
||||
return s.configData["menu"], true, nil
|
||||
default:
|
||||
return nil, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// 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()
|
||||
func (s *Service) handleConfigTask(c *core.Core, t core.Task) (any, bool, error) {
|
||||
switch t := t.(type) {
|
||||
case window.TaskSaveConfig:
|
||||
s.configData["window"] = t.Value
|
||||
return nil, true, nil
|
||||
case systray.TaskSaveConfig:
|
||||
s.configData["systray"] = t.Value
|
||||
return nil, true, nil
|
||||
case menu.TaskSaveConfig:
|
||||
s.configData["menu"] = t.Value
|
||||
return nil, true, nil
|
||||
default:
|
||||
return nil, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// --- Window Management (delegates to window.Manager) ---
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package display
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/core"
|
||||
|
|
@ -161,13 +162,6 @@ func (mi *displayMockMenuItem) OnClick(fn func()) menu.PlatformMenuItem
|
|||
|
||||
// --- Test helpers ---
|
||||
|
||||
// newTestCore creates a new core instance for testing.
|
||||
func newTestCore(t *testing.T) *core.Core {
|
||||
coreInstance, err := core.New()
|
||||
require.NoError(t, err)
|
||||
return coreInstance
|
||||
}
|
||||
|
||||
// newServiceWithMocks creates a Service with mock sub-managers for testing.
|
||||
// Uses a temp directory for state/layout persistence to avoid loading real saved state.
|
||||
func newServiceWithMocks(t *testing.T) (*Service, *mockApp, *displayMockWindowPlatform) {
|
||||
|
|
@ -185,6 +179,19 @@ func newServiceWithMocks(t *testing.T) (*Service, *mockApp, *displayMockWindowPl
|
|||
return service, mock, wp
|
||||
}
|
||||
|
||||
// newTestDisplayService creates a display service registered with Core for IPC testing.
|
||||
func newTestDisplayService(t *testing.T) (*Service, *core.Core) {
|
||||
t.Helper()
|
||||
c, err := core.New(
|
||||
core.WithService(Register(nil)),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
||||
svc := core.MustServiceFor[*Service](c, "display")
|
||||
return svc, c
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
|
|
@ -203,31 +210,56 @@ func TestNew(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestRegister(t *testing.T) {
|
||||
t.Run("registers with core successfully", func(t *testing.T) {
|
||||
coreInstance := newTestCore(t)
|
||||
service, err := Register(coreInstance)
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, service, "Register() should return a non-nil service instance")
|
||||
})
|
||||
func TestRegisterClosure_Good(t *testing.T) {
|
||||
factory := Register(nil) // nil wailsApp for testing
|
||||
assert.NotNil(t, factory)
|
||||
|
||||
t.Run("returns Service type", func(t *testing.T) {
|
||||
coreInstance := newTestCore(t)
|
||||
service, err := Register(coreInstance)
|
||||
require.NoError(t, err)
|
||||
c, err := core.New(
|
||||
core.WithService(factory),
|
||||
core.WithServiceLock(),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, c.ServiceStartup(context.Background(), nil))
|
||||
|
||||
displayService, ok := service.(*Service)
|
||||
assert.True(t, ok, "Register() should return *Service type")
|
||||
assert.NotNil(t, displayService.ServiceRuntime, "ServiceRuntime should be initialized")
|
||||
})
|
||||
svc := core.MustServiceFor[*Service](c, "display")
|
||||
assert.NotNil(t, svc)
|
||||
}
|
||||
|
||||
func TestServiceName(t *testing.T) {
|
||||
service, err := New()
|
||||
require.NoError(t, err)
|
||||
func TestConfigQuery_Good(t *testing.T) {
|
||||
svc, c := newTestDisplayService(t)
|
||||
|
||||
name := service.ServiceName()
|
||||
assert.Equal(t, "forge.lthn.ai/core/gui/display", name)
|
||||
// Set window config
|
||||
svc.configData["window"] = map[string]any{
|
||||
"default_width": 1024,
|
||||
}
|
||||
|
||||
result, handled, err := c.QUERY(window.QueryConfig{})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
cfg := result.(map[string]any)
|
||||
assert.Equal(t, 1024, cfg["default_width"])
|
||||
}
|
||||
|
||||
func TestConfigQuery_Bad(t *testing.T) {
|
||||
// No display service — window config query returns handled=false
|
||||
c, err := core.New(core.WithServiceLock())
|
||||
require.NoError(t, err)
|
||||
_, handled, _ := c.QUERY(window.QueryConfig{})
|
||||
assert.False(t, handled)
|
||||
}
|
||||
|
||||
func TestConfigTask_Good(t *testing.T) {
|
||||
_, c := newTestDisplayService(t)
|
||||
|
||||
newCfg := map[string]any{"default_width": 800}
|
||||
_, handled, err := c.PERFORM(window.TaskSaveConfig{Value: newCfg})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, handled)
|
||||
|
||||
// Verify config was saved
|
||||
result, _, _ := c.QUERY(window.QueryConfig{})
|
||||
cfg := result.(map[string]any)
|
||||
assert.Equal(t, 800, cfg["default_width"])
|
||||
}
|
||||
|
||||
func TestOpenWindow_Good(t *testing.T) {
|
||||
|
|
@ -461,8 +493,8 @@ func TestShowEnvironmentDialog_Good(t *testing.T) {
|
|||
|
||||
func TestBuildMenu_Good(t *testing.T) {
|
||||
service, _, _ := newServiceWithMocks(t)
|
||||
coreInstance := newTestCore(t)
|
||||
service.ServiceRuntime = core.NewServiceRuntime[Options](coreInstance, Options{})
|
||||
c, _ := core.New()
|
||||
service.ServiceRuntime = core.NewServiceRuntime[Options](c, Options{})
|
||||
|
||||
// buildMenu should not panic with mock platforms
|
||||
assert.NotPanics(t, func() {
|
||||
|
|
@ -472,8 +504,8 @@ func TestBuildMenu_Good(t *testing.T) {
|
|||
|
||||
func TestSetupTray_Good(t *testing.T) {
|
||||
service, _, _ := newServiceWithMocks(t)
|
||||
coreInstance := newTestCore(t)
|
||||
service.ServiceRuntime = core.NewServiceRuntime[Options](coreInstance, Options{})
|
||||
c, _ := core.New()
|
||||
service.ServiceRuntime = core.NewServiceRuntime[Options](c, Options{})
|
||||
|
||||
// setupTray should not panic with mock platforms
|
||||
assert.NotPanics(t, func() {
|
||||
|
|
@ -499,8 +531,8 @@ func TestHandleNewWorkspace_Good(t *testing.T) {
|
|||
|
||||
func TestHandleListWorkspaces_Good(t *testing.T) {
|
||||
service, _, _ := newServiceWithMocks(t)
|
||||
coreInstance := newTestCore(t)
|
||||
service.ServiceRuntime = core.NewServiceRuntime[Options](coreInstance, Options{})
|
||||
c, _ := core.New()
|
||||
service.ServiceRuntime = core.NewServiceRuntime[Options](c, Options{})
|
||||
|
||||
// handleListWorkspaces should not panic when workspace service is not available
|
||||
assert.NotPanics(t, func() {
|
||||
|
|
@ -565,19 +597,3 @@ func TestGetSavedWindowStates_Good(t *testing.T) {
|
|||
states := service.GetSavedWindowStates()
|
||||
assert.NotNil(t, states)
|
||||
}
|
||||
|
||||
func TestActionOpenWindow_Good(t *testing.T) {
|
||||
action := ActionOpenWindow{
|
||||
Window: window.Window{
|
||||
Name: "test",
|
||||
Title: "Test Window",
|
||||
Width: 800,
|
||||
Height: 600,
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, "test", action.Name)
|
||||
assert.Equal(t, "Test Window", action.Title)
|
||||
assert.Equal(t, 800, action.Width)
|
||||
assert.Equal(t, 600, action.Height)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,8 +21,10 @@ const (
|
|||
EventWindowResize EventType = "window.resize"
|
||||
EventWindowClose EventType = "window.close"
|
||||
EventWindowCreate EventType = "window.create"
|
||||
EventThemeChange EventType = "theme.change"
|
||||
EventScreenChange EventType = "screen.change"
|
||||
EventThemeChange EventType = "theme.change"
|
||||
EventScreenChange EventType = "screen.change"
|
||||
EventTrayClick EventType = "tray.click"
|
||||
EventTrayMenuItemClick EventType = "tray.menuitem.click"
|
||||
)
|
||||
|
||||
// Event represents a display event sent to subscribers.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue