diff --git a/pkg/screen/messages.go b/pkg/screen/messages.go index 0775384..607b209 100644 --- a/pkg/screen/messages.go +++ b/pkg/screen/messages.go @@ -16,5 +16,11 @@ type QueryAtPoint struct{ X, Y int } // QueryWorkAreas returns work areas for all screens. Result: []Rect type QueryWorkAreas struct{} +// QueryCurrent returns the most recently active screen. Result: *Screen (nil if no screens registered) +// +// result, _, _ := c.QUERY(screen.QueryCurrent{}) +// current := result.(*screen.Screen) +type QueryCurrent 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 index 97d950d..65e1c90 100644 --- a/pkg/screen/platform.go +++ b/pkg/screen/platform.go @@ -2,24 +2,33 @@ package screen // Platform abstracts the screen/display backend. +// +// core.WithService(screen.Register(wailsPlatform)) type Platform interface { GetAll() []Screen GetPrimary() *Screen + // GetCurrent returns the most recently active screen, or the primary if unset. + // current := platform.GetCurrent() + GetCurrent() *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"` + ID string `json:"id"` + Name string `json:"name"` + ScaleFactor float64 `json:"scaleFactor"` + Size Size `json:"size"` + Bounds Rect `json:"bounds"` + PhysicalBounds Rect `json:"physicalBounds"` + WorkArea Rect `json:"workArea"` + PhysicalWorkArea Rect `json:"physicalWorkArea"` + IsPrimary bool `json:"isPrimary"` + Rotation float64 `json:"rotation"` } // Rect represents a rectangle with position and dimensions. +// +// if bounds.Contains(Point{X: cursor.X, Y: cursor.Y}) { highlightWindow() } type Rect struct { X int `json:"x"` Y int `json:"y"` @@ -27,8 +36,166 @@ type Rect struct { Height int `json:"height"` } +// Origin returns the top-left corner of the rectangle. +// +// pt := bounds.Origin() // Point{X: bounds.X, Y: bounds.Y} +func (r Rect) Origin() Point { + return Point{X: r.X, Y: r.Y} +} + +// Corner returns the exclusive bottom-right corner (X+Width, Y+Height). +// +// end := bounds.Corner() // Point{X: bounds.X+bounds.Width, Y: bounds.Y+bounds.Height} +func (r Rect) Corner() Point { + return Point{X: r.X + r.Width, Y: r.Y + r.Height} +} + +// InsideCorner returns the inclusive bottom-right corner (X+Width-1, Y+Height-1). +// +// last := bounds.InsideCorner() +func (r Rect) InsideCorner() Point { + return Point{X: r.X + r.Width - 1, Y: r.Y + r.Height - 1} +} + +// IsEmpty reports whether the rectangle has non-positive area. +// +// if r.IsEmpty() { return } +func (r Rect) IsEmpty() bool { + return r.Width <= 0 || r.Height <= 0 +} + +// Contains reports whether point pt lies within the rectangle. +// +// if workArea.Contains(windowOrigin) { snapToScreen() } +func (r Rect) Contains(pt Point) bool { + return pt.X >= r.X && pt.X < r.X+r.Width && pt.Y >= r.Y && pt.Y < r.Y+r.Height +} + +// RectSize returns the dimensions of the rectangle as a Size value. +// +// sz := bounds.RectSize() // Size{Width: bounds.Width, Height: bounds.Height} +func (r Rect) RectSize() Size { + return Size{Width: r.Width, Height: r.Height} +} + +// Intersect returns the overlapping region of r and other, or an empty Rect if they do not overlap. +// +// overlap := a.Intersect(b) +// if !overlap.IsEmpty() { handleOverlap(overlap) } +func (r Rect) Intersect(other Rect) Rect { + if r.IsEmpty() || other.IsEmpty() { + return Rect{} + } + maxLeft := max(r.X, other.X) + maxTop := max(r.Y, other.Y) + minRight := min(r.X+r.Width, other.X+other.Width) + minBottom := min(r.Y+r.Height, other.Y+other.Height) + if minRight > maxLeft && minBottom > maxTop { + return Rect{X: maxLeft, Y: maxTop, Width: minRight - maxLeft, Height: minBottom - maxTop} + } + return Rect{} +} + +// Point is a two-dimensional coordinate. +// +// centre := Point{X: bounds.X + bounds.Width/2, Y: bounds.Y + bounds.Height/2} +type Point struct { + X int `json:"x"` + Y int `json:"y"` +} + // Size represents dimensions. type Size struct { Width int `json:"width"` Height int `json:"height"` } + +// Alignment describes which edge of a parent screen a child screen is placed against. +type Alignment int + +const ( + AlignTop Alignment = iota // child is above parent + AlignRight // child is to the right of parent + AlignBottom // child is below parent + AlignLeft // child is to the left of parent +) + +// OffsetReference specifies whether the placement offset is measured from the +// beginning (top/left) or end (bottom/right) of the parent edge. +type OffsetReference int + +const ( + OffsetBegin OffsetReference = iota // offset from top or left + OffsetEnd // offset from bottom or right +) + +// ScreenPlacement positions a screen relative to a parent screen. +// +// placement := screen.NewPlacement(parent, AlignRight, 0, OffsetBegin) +// placement.Apply() +type ScreenPlacement struct { + screen *Screen + parent *Screen + alignment Alignment + offset int + offsetReference OffsetReference +} + +// NewPlacement creates a ScreenPlacement that positions screen relative to parent. +// +// p := NewPlacement(secondary, primary, AlignRight, 0, OffsetBegin) +// p.Apply() +func NewPlacement(screen, parent *Screen, alignment Alignment, offset int, reference OffsetReference) ScreenPlacement { + return ScreenPlacement{ + screen: screen, + parent: parent, + alignment: alignment, + offset: offset, + offsetReference: reference, + } +} + +// Apply moves screen.Bounds so that it sits against the specified edge of parent. +// +// NewPlacement(s, p, AlignRight, 0, OffsetBegin).Apply() +func (p ScreenPlacement) Apply() { + parentBounds := p.parent.Bounds + screenBounds := p.screen.Bounds + + newX := parentBounds.X + newY := parentBounds.Y + offset := p.offset + + if p.alignment == AlignTop || p.alignment == AlignBottom { + if p.offsetReference == OffsetEnd { + offset = parentBounds.Width - offset - screenBounds.Width + } + offset = min(offset, parentBounds.Width) + offset = max(offset, -screenBounds.Width) + newX += offset + if p.alignment == AlignTop { + newY -= screenBounds.Height + } else { + newY += parentBounds.Height + } + } else { + if p.offsetReference == OffsetEnd { + offset = parentBounds.Height - offset - screenBounds.Height + } + offset = min(offset, parentBounds.Height) + offset = max(offset, -screenBounds.Height) + newY += offset + if p.alignment == AlignLeft { + newX -= screenBounds.Width + } else { + newX += parentBounds.Width + } + } + + workAreaOffsetX := p.screen.WorkArea.X - p.screen.Bounds.X + workAreaOffsetY := p.screen.WorkArea.Y - p.screen.Bounds.Y + p.screen.Bounds.X = newX + p.screen.Bounds.Y = newY + p.screen.WorkArea.X = newX + workAreaOffsetX + p.screen.WorkArea.Y = newY + workAreaOffsetY +} diff --git a/pkg/screen/service.go b/pkg/screen/service.go index 87b22ae..085ac62 100644 --- a/pkg/screen/service.go +++ b/pkg/screen/service.go @@ -46,6 +46,8 @@ func (s *Service) handleQuery(c *core.Core, q core.Query) (any, bool, error) { return s.queryAtPoint(q.X, q.Y), true, nil case QueryWorkAreas: return s.queryWorkAreas(), true, nil + case QueryCurrent: + return s.platform.GetCurrent(), true, nil default: return nil, false, nil } diff --git a/pkg/screen/service_test.go b/pkg/screen/service_test.go index 56c0833..736aec1 100644 --- a/pkg/screen/service_test.go +++ b/pkg/screen/service_test.go @@ -12,6 +12,7 @@ import ( type mockPlatform struct { screens []Screen + current *Screen } func (m *mockPlatform) GetAll() []Screen { return m.screens } @@ -23,6 +24,12 @@ func (m *mockPlatform) GetPrimary() *Screen { } return nil } +func (m *mockPlatform) GetCurrent() *Screen { + if m.current != nil { + return m.current + } + return m.GetPrimary() +} func newTestService(t *testing.T) (*mockPlatform, *core.Core) { t.Helper() @@ -130,3 +137,186 @@ func TestQueryWorkAreas_Good(t *testing.T) { assert.Len(t, areas, 2) assert.Equal(t, 38, areas[0].Y) // primary has menu bar offset } + +// --- QueryCurrent --- + +func TestQueryCurrent_Good(t *testing.T) { + // current falls back to primary when not explicitly set + _, c := newTestService(t) + result, handled, err := c.QUERY(QueryCurrent{}) + require.NoError(t, err) + assert.True(t, handled) + scr := result.(*Screen) + require.NotNil(t, scr) + assert.True(t, scr.IsPrimary) + assert.Equal(t, "Built-in", scr.Name) +} + +func TestQueryCurrent_Bad(t *testing.T) { + // no screens at all → GetCurrent returns nil + mock := &mockPlatform{screens: []Screen{}} + c, err := core.New( + core.WithService(Register(mock)), + core.WithServiceLock(), + ) + require.NoError(t, err) + require.NoError(t, c.ServiceStartup(context.Background(), nil)) + + result, handled, err := c.QUERY(QueryCurrent{}) + require.NoError(t, err) + assert.True(t, handled) + assert.Nil(t, result) +} + +func TestQueryCurrent_Ugly(t *testing.T) { + // current is explicitly set to the external screen + mock := &mockPlatform{ + screens: []Screen{ + {ID: "1", Name: "Built-in", IsPrimary: true, + Bounds: Rect{X: 0, Y: 0, Width: 2560, Height: 1600}}, + {ID: "2", Name: "External", + Bounds: Rect{X: 2560, Y: 0, Width: 1920, Height: 1080}}, + }, + } + mock.current = &mock.screens[1] + c, err := core.New( + core.WithService(Register(mock)), + core.WithServiceLock(), + ) + require.NoError(t, err) + require.NoError(t, c.ServiceStartup(context.Background(), nil)) + + result, _, _ := c.QUERY(QueryCurrent{}) + scr := result.(*Screen) + require.NotNil(t, scr) + assert.Equal(t, "External", scr.Name) +} + +// --- Rect geometry helpers --- + +func TestRect_Origin_Good(t *testing.T) { + r := Rect{X: 10, Y: 20, Width: 100, Height: 50} + pt := r.Origin() + assert.Equal(t, Point{X: 10, Y: 20}, pt) +} + +func TestRect_Corner_Good(t *testing.T) { + r := Rect{X: 10, Y: 20, Width: 100, Height: 50} + pt := r.Corner() + assert.Equal(t, Point{X: 110, Y: 70}, pt) +} + +func TestRect_InsideCorner_Good(t *testing.T) { + r := Rect{X: 10, Y: 20, Width: 100, Height: 50} + pt := r.InsideCorner() + assert.Equal(t, Point{X: 109, Y: 69}, pt) +} + +func TestRect_IsEmpty_Good(t *testing.T) { + assert.False(t, Rect{X: 0, Y: 0, Width: 1, Height: 1}.IsEmpty()) +} + +func TestRect_IsEmpty_Bad(t *testing.T) { + assert.True(t, Rect{}.IsEmpty()) + assert.True(t, Rect{Width: 0, Height: 10}.IsEmpty()) + assert.True(t, Rect{Width: 10, Height: -1}.IsEmpty()) +} + +func TestRect_Contains_Good(t *testing.T) { + r := Rect{X: 0, Y: 0, Width: 100, Height: 100} + assert.True(t, r.Contains(Point{X: 0, Y: 0})) + assert.True(t, r.Contains(Point{X: 50, Y: 50})) + assert.True(t, r.Contains(Point{X: 99, Y: 99})) +} + +func TestRect_Contains_Bad(t *testing.T) { + r := Rect{X: 0, Y: 0, Width: 100, Height: 100} + // exclusive right/bottom edge + assert.False(t, r.Contains(Point{X: 100, Y: 50})) + assert.False(t, r.Contains(Point{X: 50, Y: 100})) + assert.False(t, r.Contains(Point{X: -1, Y: 50})) +} + +func TestRect_Contains_Ugly(t *testing.T) { + // zero-size rect never contains anything + r := Rect{X: 5, Y: 5, Width: 0, Height: 0} + assert.False(t, r.Contains(Point{X: 5, Y: 5})) +} + +func TestRect_RectSize_Good(t *testing.T) { + r := Rect{X: 100, Y: 200, Width: 1920, Height: 1080} + sz := r.RectSize() + assert.Equal(t, Size{Width: 1920, Height: 1080}, sz) +} + +func TestRect_Intersect_Good(t *testing.T) { + a := Rect{X: 0, Y: 0, Width: 100, Height: 100} + b := Rect{X: 50, Y: 50, Width: 100, Height: 100} + overlap := a.Intersect(b) + assert.Equal(t, Rect{X: 50, Y: 50, Width: 50, Height: 50}, overlap) +} + +func TestRect_Intersect_Bad(t *testing.T) { + // no overlap + a := Rect{X: 0, Y: 0, Width: 50, Height: 50} + b := Rect{X: 100, Y: 100, Width: 50, Height: 50} + overlap := a.Intersect(b) + assert.True(t, overlap.IsEmpty()) +} + +func TestRect_Intersect_Ugly(t *testing.T) { + // empty rect intersects nothing + a := Rect{X: 0, Y: 0, Width: 0, Height: 0} + b := Rect{X: 0, Y: 0, Width: 100, Height: 100} + overlap := a.Intersect(b) + assert.True(t, overlap.IsEmpty()) +} + +// --- ScreenPlacement --- + +func TestScreenPlacement_Apply_Good(t *testing.T) { + // secondary placed to the RIGHT of primary, no offset + primary := &Screen{ + Bounds: Rect{X: 0, Y: 0, Width: 2560, Height: 1600}, + WorkArea: Rect{X: 0, Y: 38, Width: 2560, Height: 1562}, + } + secondary := &Screen{ + Bounds: Rect{X: 3000, Y: 0, Width: 1920, Height: 1080}, + WorkArea: Rect{X: 3000, Y: 0, Width: 1920, Height: 1080}, + } + NewPlacement(secondary, primary, AlignRight, 0, OffsetBegin).Apply() + assert.Equal(t, 2560, secondary.Bounds.X) + assert.Equal(t, 0, secondary.Bounds.Y) + assert.Equal(t, 2560, secondary.WorkArea.X) +} + +func TestScreenPlacement_Apply_Bad(t *testing.T) { + // screen placed ABOVE primary: newY = primary.Y - secondary.Height + primary := &Screen{ + Bounds: Rect{X: 0, Y: 0, Width: 1920, Height: 1080}, + WorkArea: Rect{X: 0, Y: 0, Width: 1920, Height: 1080}, + } + secondary := &Screen{ + Bounds: Rect{X: 0, Y: -600, Width: 1920, Height: 600}, + WorkArea: Rect{X: 0, Y: -600, Width: 1920, Height: 600}, + } + NewPlacement(secondary, primary, AlignTop, 0, OffsetBegin).Apply() + assert.Equal(t, 0, secondary.Bounds.X) + assert.Equal(t, -600, secondary.Bounds.Y) +} + +func TestScreenPlacement_Apply_Ugly(t *testing.T) { + // END offset reference — places secondary flush to the bottom-right of parent + primary := &Screen{ + Bounds: Rect{X: 0, Y: 0, Width: 1920, Height: 1080}, + WorkArea: Rect{X: 0, Y: 0, Width: 1920, Height: 1080}, + } + secondary := &Screen{ + Bounds: Rect{X: 0, Y: 0, Width: 800, Height: 600}, + WorkArea: Rect{X: 0, Y: 0, Width: 800, Height: 600}, + } + // AlignBottom + OffsetEnd + offset=0 → secondary starts at right edge of parent + NewPlacement(secondary, primary, AlignBottom, 0, OffsetEnd).Apply() + assert.Equal(t, 1920-800, secondary.Bounds.X) // flush right + assert.Equal(t, 1080, secondary.Bounds.Y) // just below parent +}