From 91f4532b5030c09a81f57a49427faeb025856c7b Mon Sep 17 00:00:00 2001 From: Snider Date: Fri, 13 Mar 2026 14:19:56 +0000 Subject: [PATCH] feat(screen): add screen core.Service with computed queries via IPC Co-Authored-By: Claude Opus 4.6 --- pkg/screen/messages.go | 20 ++++++ pkg/screen/platform.go | 34 ++++++++++ pkg/screen/service.go | 83 +++++++++++++++++++++++ pkg/screen/service_test.go | 132 +++++++++++++++++++++++++++++++++++++ 4 files changed, 269 insertions(+) create mode 100644 pkg/screen/messages.go create mode 100644 pkg/screen/platform.go create mode 100644 pkg/screen/service.go create mode 100644 pkg/screen/service_test.go diff --git a/pkg/screen/messages.go b/pkg/screen/messages.go new file mode 100644 index 0000000..0775384 --- /dev/null +++ b/pkg/screen/messages.go @@ -0,0 +1,20 @@ +// pkg/screen/messages.go +package screen + +// QueryAll returns all screens. Result: []Screen +type QueryAll struct{} + +// QueryPrimary returns the primary screen. Result: *Screen (nil if not found) +type QueryPrimary struct{} + +// QueryByID returns a screen by ID. Result: *Screen (nil if not found) +type QueryByID struct{ ID string } + +// QueryAtPoint returns the screen containing a point. Result: *Screen (nil if none) +type QueryAtPoint struct{ X, Y int } + +// QueryWorkAreas returns work areas for all screens. Result: []Rect +type QueryWorkAreas struct{} + +// ActionScreensChanged is broadcast when displays change (future). +type ActionScreensChanged struct{ Screens []Screen } diff --git a/pkg/screen/platform.go b/pkg/screen/platform.go new file mode 100644 index 0000000..97d950d --- /dev/null +++ b/pkg/screen/platform.go @@ -0,0 +1,34 @@ +// pkg/screen/platform.go +package screen + +// Platform abstracts the screen/display backend. +type Platform interface { + GetAll() []Screen + GetPrimary() *Screen +} + +// Screen describes a display/monitor. +type Screen struct { + ID string `json:"id"` + Name string `json:"name"` + ScaleFactor float64 `json:"scaleFactor"` + Size Size `json:"size"` + Bounds Rect `json:"bounds"` + WorkArea Rect `json:"workArea"` + IsPrimary bool `json:"isPrimary"` + Rotation float64 `json:"rotation"` +} + +// Rect represents a rectangle with position and dimensions. +type Rect struct { + X int `json:"x"` + Y int `json:"y"` + Width int `json:"width"` + Height int `json:"height"` +} + +// Size represents dimensions. +type Size struct { + Width int `json:"width"` + Height int `json:"height"` +} diff --git a/pkg/screen/service.go b/pkg/screen/service.go new file mode 100644 index 0000000..29db455 --- /dev/null +++ b/pkg/screen/service.go @@ -0,0 +1,83 @@ +// pkg/screen/service.go +package screen + +import ( + "context" + + "forge.lthn.ai/core/go/pkg/core" +) + +// Options holds configuration for the screen service. +type Options struct{} + +// Service is a core.Service providing screen/display queries via IPC. +type Service struct { + *core.ServiceRuntime[Options] + platform Platform +} + +// Register creates a factory closure that captures the Platform adapter. +func Register(p Platform) func(*core.Core) (any, error) { + return func(c *core.Core) (any, error) { + return &Service{ + ServiceRuntime: core.NewServiceRuntime[Options](c, Options{}), + platform: p, + }, nil + } +} + +// OnStartup registers IPC handlers. +func (s *Service) OnStartup(ctx context.Context) error { + s.Core().RegisterQuery(s.handleQuery) + return nil +} + +// HandleIPCEvents is auto-discovered by core.WithService. +func (s *Service) HandleIPCEvents(c *core.Core, msg core.Message) error { + return nil +} + +func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) { + switch q := q.(type) { + case QueryAll: + return s.platform.GetAll(), true, nil + case QueryPrimary: + return s.platform.GetPrimary(), true, nil + case QueryByID: + return s.queryByID(q.ID), true, nil + case QueryAtPoint: + return s.queryAtPoint(q.X, q.Y), true, nil + case QueryWorkAreas: + return s.queryWorkAreas(), true, nil + default: + return nil, false, nil + } +} + +func (s *Service) queryByID(id string) *Screen { + for _, scr := range s.platform.GetAll() { + if scr.ID == id { + return &scr + } + } + return nil +} + +func (s *Service) queryAtPoint(x, y int) *Screen { + for _, scr := range s.platform.GetAll() { + b := scr.Bounds + if x >= b.X && x < b.X+b.Width && y >= b.Y && y < b.Y+b.Height { + return &scr + } + } + return nil +} + +func (s *Service) queryWorkAreas() []Rect { + screens := s.platform.GetAll() + areas := make([]Rect, len(screens)) + for i, scr := range screens { + areas[i] = scr.WorkArea + } + return areas +} diff --git a/pkg/screen/service_test.go b/pkg/screen/service_test.go new file mode 100644 index 0000000..56c0833 --- /dev/null +++ b/pkg/screen/service_test.go @@ -0,0 +1,132 @@ +// pkg/screen/service_test.go +package screen + +import ( + "context" + "testing" + + "forge.lthn.ai/core/go/pkg/core" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockPlatform struct { + screens []Screen +} + +func (m *mockPlatform) GetAll() []Screen { return m.screens } +func (m *mockPlatform) GetPrimary() *Screen { + for i := range m.screens { + if m.screens[i].IsPrimary { + return &m.screens[i] + } + } + return nil +} + +func newTestService(t *testing.T) (*mockPlatform, *core.Core) { + t.Helper() + mock := &mockPlatform{ + screens: []Screen{ + { + ID: "1", Name: "Built-in", IsPrimary: true, + Bounds: Rect{X: 0, Y: 0, Width: 2560, Height: 1600}, + WorkArea: Rect{X: 0, Y: 38, Width: 2560, Height: 1562}, + Size: Size{Width: 2560, Height: 1600}, + }, + { + ID: "2", Name: "External", + Bounds: Rect{X: 2560, Y: 0, Width: 1920, Height: 1080}, + WorkArea: Rect{X: 2560, Y: 0, Width: 1920, Height: 1080}, + Size: Size{Width: 1920, Height: 1080}, + }, + }, + } + c, err := core.New( + core.WithService(Register(mock)), + core.WithServiceLock(), + ) + require.NoError(t, err) + require.NoError(t, c.ServiceStartup(context.Background(), nil)) + return mock, c +} + +func TestRegister_Good(t *testing.T) { + _, c := newTestService(t) + svc := core.MustServiceFor[*Service](c, "screen") + assert.NotNil(t, svc) +} + +func TestQueryAll_Good(t *testing.T) { + _, c := newTestService(t) + result, handled, err := c.QUERY(QueryAll{}) + require.NoError(t, err) + assert.True(t, handled) + screens := result.([]Screen) + assert.Len(t, screens, 2) +} + +func TestQueryPrimary_Good(t *testing.T) { + _, c := newTestService(t) + result, handled, err := c.QUERY(QueryPrimary{}) + require.NoError(t, err) + assert.True(t, handled) + scr := result.(*Screen) + require.NotNil(t, scr) + assert.Equal(t, "Built-in", scr.Name) + assert.True(t, scr.IsPrimary) +} + +func TestQueryByID_Good(t *testing.T) { + _, c := newTestService(t) + result, handled, err := c.QUERY(QueryByID{ID: "2"}) + require.NoError(t, err) + assert.True(t, handled) + scr := result.(*Screen) + require.NotNil(t, scr) + assert.Equal(t, "External", scr.Name) +} + +func TestQueryByID_Bad(t *testing.T) { + _, c := newTestService(t) + result, handled, err := c.QUERY(QueryByID{ID: "99"}) + require.NoError(t, err) + assert.True(t, handled) + assert.Nil(t, result) +} + +func TestQueryAtPoint_Good(t *testing.T) { + _, c := newTestService(t) + + // Point on primary screen + result, handled, err := c.QUERY(QueryAtPoint{X: 100, Y: 100}) + require.NoError(t, err) + assert.True(t, handled) + scr := result.(*Screen) + require.NotNil(t, scr) + assert.Equal(t, "Built-in", scr.Name) + + // Point on external screen + result, _, _ = c.QUERY(QueryAtPoint{X: 3000, Y: 500}) + scr = result.(*Screen) + require.NotNil(t, scr) + assert.Equal(t, "External", scr.Name) +} + +func TestQueryAtPoint_Bad(t *testing.T) { + _, c := newTestService(t) + result, handled, err := c.QUERY(QueryAtPoint{X: -1000, Y: -1000}) + require.NoError(t, err) + assert.True(t, handled) + assert.Nil(t, result) +} + +func TestQueryWorkAreas_Good(t *testing.T) { + _, c := newTestService(t) + result, handled, err := c.QUERY(QueryWorkAreas{}) + require.NoError(t, err) + assert.True(t, handled) + areas := result.([]Rect) + assert.Len(t, areas, 2) + assert.Equal(t, 38, areas[0].Y) // primary has menu bar offset +}