feat(screen): add screen core.Service with computed queries via IPC
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
a7c976ff3c
commit
91f4532b50
4 changed files with 269 additions and 0 deletions
20
pkg/screen/messages.go
Normal file
20
pkg/screen/messages.go
Normal file
|
|
@ -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 }
|
||||
34
pkg/screen/platform.go
Normal file
34
pkg/screen/platform.go
Normal file
|
|
@ -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"`
|
||||
}
|
||||
83
pkg/screen/service.go
Normal file
83
pkg/screen/service.go
Normal file
|
|
@ -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
|
||||
}
|
||||
132
pkg/screen/service_test.go
Normal file
132
pkg/screen/service_test.go
Normal file
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue