feat(screen): expand with QueryCurrent, ScreenPlacement, Rect geometry
- Platform.GetCurrent() for active screen query - ScreenPlacement with Apply() for window positioning rules - Rect geometry: Origin, Corner, InsideCorner, IsEmpty, Contains, RectSize, Intersect - Point type, Alignment/OffsetReference constants - 18 new tests with Good/Bad/Ugly coverage Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ee34ed5b26
commit
ab0722d19e
4 changed files with 373 additions and 8 deletions
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue