diff --git a/examples/displays/borderless/main.go b/examples/displays/borderless/main.go new file mode 100644 index 00000000..e05e8647 --- /dev/null +++ b/examples/displays/borderless/main.go @@ -0,0 +1,127 @@ +package main + +import ( + "fmt" + + "github.com/jacekolszak/pixiq/colornames" + "github.com/jacekolszak/pixiq/glclear" + "github.com/jacekolszak/pixiq/glfw" + "github.com/jacekolszak/pixiq/mouse" +) + +func main() { + // This example shows how to switch from windowed to full screen mode + glfw.StartMainThreadLoop(func(mainThreadLoop *glfw.MainThreadLoop) { + // Displays instance requires mainThreadLoop because accessing information + // about displays must be done from the main thread. + displays, err := glfw.Displays(mainThreadLoop) + if err != nil { + panic(err) + } + + gl, err := glfw.NewOpenGL(mainThreadLoop) + if err != nil { + panic(err) + } + // Open standard window + screenWidth, screenHeight := 640, 360 + win, err := gl.OpenWindow(screenWidth, screenHeight, glfw.Title("Press left mouse button to borderless fullscreen")) + if err != nil { + panic(err) + } + + mouseState := mouse.New(win) + + fullscreen := false + + prepareScreen(gl, win) + + for { + mouseState.Update() + if mouseState.JustReleased(mouse.Left) { + if !fullscreen { + display := currentDisplay(win, displays) + mode := display.VideoMode() + // Disable automatic iconify on focus loss. + win.SetAutoIconifyHint(false) + zoom := adjustZoom(mode, screenWidth, screenHeight) + fmt.Println(zoom) + // Turn on the full screen + win.EnterFullScreen(mode, zoom) + fullscreen = true + } else { + win.ExitFullScreen() + fullscreen = false + } + } + win.Draw() + if win.ShouldClose() { + break + } + } + + }) +} + +func prepareScreen(gl *glfw.OpenGL, win *glfw.Window) { + clearTool := glclear.New(gl.Context()) + clearTool.SetColor(colornames.White) + screen := win.Screen() + clearTool.Clear(screen) + clearTool.SetColor(colornames.Black) + clearTool.Clear(screen.Selection(270, 130).WithSize(100, 100)) +} + +func currentDisplay(win *glfw.Window, displays *glfw.DisplaysAPI) glfw.Display { + // TODO This functionality should be in a new package + highestArea := 0 + all := displays.All() + bestDisplay := all[0] + for _, display := range all { + workarea := display.Workarea() + videoMode := display.VideoMode() + left := max(win.X(), workarea.X()) + right := min(videoMode.Width()+workarea.X(), win.Width()+win.X()) + top := max(win.Y(), workarea.Y()) + bottom := min(videoMode.Height()+workarea.Y(), win.Height()+win.Y()) + w := right - left + h := bottom - top + if w > 0 && h > 0 { + area := w * h + if area > highestArea { + bestDisplay = display + highestArea = area + } + } + } + return bestDisplay +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func adjustZoom(mode glfw.VideoMode, width, height int) (zoom int) { + zoom = 1 + w := width + h := height + for mode.Width() > w && mode.Height() > h { + zoom++ + w = width * zoom + h = height * zoom + } + if w > mode.Width() || h > mode.Height() { + zoom-- + } + return zoom +} diff --git a/examples/displays/fullscreen/main.go b/examples/displays/fullscreen/main.go new file mode 100644 index 00000000..9eba51c0 --- /dev/null +++ b/examples/displays/fullscreen/main.go @@ -0,0 +1,74 @@ +package main + +import ( + "fmt" + "github.com/jacekolszak/pixiq/clear" + "github.com/jacekolszak/pixiq/colornames" + "github.com/jacekolszak/pixiq/glfw" +) + +func main() { + // This example shows how to open a window in a fullscreen mode + glfw.StartMainThreadLoop(func(mainThreadLoop *glfw.MainThreadLoop) { + // Displays instance requires mainThreadLoop because accessing information + // about displays must be done from the main thread. + displays, err := glfw.Displays(mainThreadLoop) + if err != nil { + panic(err) + } + // Get Primary display. This is usually the display where elements like the Windows task bar + // or the OS X menu bar is located. + primary, ok := displays.Primary() + if !ok { + panic("no displays found") + } + // get current video mode which is usually the best one to pick (unfortunately not on MacOS) + videoMode := primary.VideoMode() + // try to find the window zoom which will give screen size close enough to requested 640x360 + zoom := adjustZoom(videoMode, 640, 360) + fmt.Printf("Adjusted zoom=%d\n", zoom) + + gl, err := glfw.NewOpenGL(mainThreadLoop) + if err != nil { + panic(err) + } + + win, err := gl.OpenFullScreenWindow(videoMode, glfw.Zoom(zoom)) + if err != nil { + panic(err) + } + + prepareScreen(win) + + // Show full screen for 3 seconds + fmt.Println("Refresh rate is", videoMode.RefreshRate()) + for x := 0; x < videoMode.RefreshRate()*3; x++ { + win.Draw() // blocks until VSync + } + win.Close() + }) +} + +// Adjusts the zoom of window based on the VideoMode and recommended screen size +func adjustZoom(mode glfw.VideoMode, width, height int) (zoom int) { + zoom = 1 + w := width + h := height + for mode.Width() > w && mode.Height() > h { + zoom++ + w = width * zoom + h = height * zoom + } + if w > mode.Width() || h > mode.Height() { + zoom-- + } + return zoom +} + +func prepareScreen(win *glfw.Window) { + screen := win.Screen() + clearTool := clear.New() + clearTool.SetColor(colornames.Lightgray) + clearTool.Clear(screen) + screen.SetColor(100, 100, colornames.Black) +} diff --git a/examples/displays/list/main.go b/examples/displays/list/main.go new file mode 100644 index 00000000..aee802ba --- /dev/null +++ b/examples/displays/list/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "fmt" + "github.com/jacekolszak/pixiq/glfw" +) + +func main() { + // This example shows how to list all displays + glfw.StartMainThreadLoop(func(mainThreadLoop *glfw.MainThreadLoop) { + // Displays instance requires mainThreadLoop because accessing information + // about displays must be done from the main thread. + displays, err := glfw.Displays(mainThreadLoop) + if err != nil { + panic(err) + } + + all := displays.All() + for _, display := range all { + fmt.Println("Name:", display.Name()) + physicalSize := display.PhysicalSize() + fmt.Printf("Phyical size: %d mm x %d mm\n", physicalSize.Width(), physicalSize.Height()) + videoMode := display.VideoMode() + fmt.Printf("Current resolution: %d x %d, %d Hz\n", videoMode.Width(), videoMode.Height(), videoMode.RefreshRate()) + fmt.Println() + } + }) +} diff --git a/examples/windows/resize/main.go b/examples/windows/resize/main.go new file mode 100644 index 00000000..646cdd0e --- /dev/null +++ b/examples/windows/resize/main.go @@ -0,0 +1,54 @@ +package main + +import ( + "github.com/jacekolszak/pixiq/clear" + "github.com/jacekolszak/pixiq/colornames" + "github.com/jacekolszak/pixiq/glfw" + "github.com/jacekolszak/pixiq/mouse" + "log" +) + +func main() { + // This example shows how to open a window in a fullscreen mode + glfw.StartMainThreadLoop(func(mainThreadLoop *glfw.MainThreadLoop) { + gl, err := glfw.NewOpenGL(mainThreadLoop) + if err != nil { + panic(err) + } + + width := 121 + height := 101 + zoom := 5 + win, err := gl.OpenWindow(width, height, glfw.Zoom(zoom), glfw.Title("Scroll the mouse wheel to zoom in/out"), glfw.Resizable(true)) + if err != nil { + panic(err) + } + + mouseState := mouse.New(win) + for { + screen := win.Screen() + clearTool := clear.New() + clearTool.SetColor(colornames.Lightgray) + clearTool.Clear(screen) + screen.SetColor(screen.Width()/2, screen.Height()/2, colornames.Black) + + mouseState.Update() + if mouseState.Scroll().Y() > 0 { + zoom++ + if err := win.Resize(width, height, zoom); err != nil { + log.Printf("Resize failed: %v", err) + } + } + if mouseState.Scroll().Y() < 0 && zoom > 5 { + zoom-- + if err := win.Resize(width, height, zoom); err != nil { + log.Printf("Resize failed: %v", err) + } + } + win.Draw() + if win.ShouldClose() { + break + } + } + }) +} diff --git a/glfw/displays.go b/glfw/displays.go new file mode 100644 index 00000000..1e7b79b3 --- /dev/null +++ b/glfw/displays.go @@ -0,0 +1,218 @@ +package glfw + +import ( + "fmt" + + "github.com/go-gl/glfw/v3.3/glfw" +) + +// Displays returns an object for getting information about +// currently connected displays. +// +// Displays instance requires MainThreadLoop because accessing information +// about displays must be done from the main thread. +func Displays(loop *MainThreadLoop) (*DisplaysAPI, error) { + var err error + loop.Execute(func() { + err = glfw.Init() + }) + if err != nil { + return nil, fmt.Errorf("glfw.Init failed: %v", err) + } + return &DisplaysAPI{loop: loop}, nil + +} + +// DisplaysAPI provides information about currently connected displays. +type DisplaysAPI struct { + loop *MainThreadLoop +} + +// Primary returns the primary display. This is usually the display +// where elements like the Windows task bar or the OS X menu bar is located. +// +// Second return value is false when primary display does not exist (possibly +// no monitors are connected) +func (m *DisplaysAPI) Primary() (*Display, bool) { + var monitor *glfw.Monitor + m.loop.Execute(func() { + monitor = glfw.GetPrimaryMonitor() + }) + if monitor == nil { + return nil, false + } + return &Display{ + monitor: monitor, + loop: m.loop, + }, true +} + +// All returns all connected displays +func (m *DisplaysAPI) All() []Display { + var all []Display + var glfwMonitors []*glfw.Monitor + m.loop.Execute(func() { + glfwMonitors = glfw.GetMonitors() + }) + for _, monitor := range glfwMonitors { + all = append(all, + Display{ + monitor: monitor, + loop: m.loop, + }) + } + return all +} + +// Display (aka monitor) provides information about display +type Display struct { + monitor *glfw.Monitor + loop *MainThreadLoop +} + +// Name returns a human-readable name of the display +func (m Display) Name() (name string) { + m.loop.Execute(func() { + name = m.monitor.GetName() + }) + return +} + +// Workarea returns the position, in pixels, of the upper-left +// corner of the work area of the specified display along with the work area +// size in pixels. +func (m Display) Workarea() (area Workarea) { + m.loop.Execute(func() { + x, y, width, height := m.monitor.GetWorkarea() + area = Workarea{ + x: x, + y: y, + width: width, + height: height, + } + }) + return +} + +// Workarea is the position, in pixels, of the upper-left +// corner of the work area of the specified display along with the work area +// size in pixels. The work area is defined as the area of the +// monitor not occluded by the operating system task bar where present. If no +// task bar exists then the work area is the monitor resolution in screen +// coordinates. +type Workarea struct { + x, y, width, height int +} + +// X coordinate of the upper-left corner of the work area in pixels +func (w Workarea) X() int { + return w.x +} + +// Y coordinate of the upper-left corner of the work area in pixels +func (w Workarea) Y() int { + return w.y +} + +// Width in pixels +func (w Workarea) Width() int { + return w.width +} + +// Height in pixels +func (w Workarea) Height() int { + return w.height +} + +// VideoMode returns the current video mode of the display +func (m Display) VideoMode() VideoMode { + var mode *glfw.VidMode + m.loop.Execute(func() { + mode = m.monitor.GetVideoMode() + }) + if mode == nil { + panic("nil mode") + } + return VideoMode{ + width: mode.Width, + height: mode.Height, + refreshRate: mode.RefreshRate, + monitor: m.monitor, + } +} + +// VideoModes returns all video modes supported by the display. +// The returned array is sorted in ascending order by resolution area +// (the product of width and height). +func (m Display) VideoModes() []VideoMode { + var modes []*glfw.VidMode + m.loop.Execute(func() { + modes = m.monitor.GetVideoModes() + }) + var videoModes []VideoMode + for _, mode := range modes { + videoModes = append(videoModes, VideoMode{ + width: mode.Width, + height: mode.Height, + refreshRate: mode.RefreshRate, + monitor: m.monitor, + }) + } + return videoModes +} + +// VideoMode contains information about display resolution in pixels +type VideoMode struct { + monitor *glfw.Monitor + width int + height int + refreshRate int +} + +// Width in pixels +func (m VideoMode) Width() int { + return m.width +} + +// Height in pixels +func (m VideoMode) Height() int { + return m.height +} + +// RefreshRate in hertz +func (m VideoMode) RefreshRate() int { + return m.refreshRate +} + +// PhysicalSize returns the size of the display area of the monitor. +func (m Display) PhysicalSize() (size PhysicalSize) { + m.loop.Execute(func() { + w, h := m.monitor.GetPhysicalSize() + size = PhysicalSize{ + width: w, + height: h, + } + }) + return +} + +// PhysicalSize returns the size, in millimetres, of the display area of the +// monitor. +// +// Note: Some operating systems do not provide accurate information, either +// because the monitor's EDID data is incorrect, or because the driver does not +// report it accurately. +type PhysicalSize struct { + width int + height int +} + +// Width in millimetres +func (s PhysicalSize) Width() int { + return s.width +} + +// Height in millimetres +func (s PhysicalSize) Height() int { + return s.height +} diff --git a/glfw/displays_test.go b/glfw/displays_test.go new file mode 100644 index 00000000..05511934 --- /dev/null +++ b/glfw/displays_test.go @@ -0,0 +1,105 @@ +package glfw_test + +import ( + "testing" + + "github.com/jacekolszak/pixiq/glfw" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// All tests here are rather plumbing tests than real tests +// verifying if glfw package integrates properly with GLFW library. + +func TestDisplays(t *testing.T) { + t.Run("should create API object", func(t *testing.T) { + displays, err := glfw.Displays(mainThreadLoop) + require.NoError(t, err) + assert.NotNil(t, displays) + }) +} + +func TestDisplaysAPI_All(t *testing.T) { + t.Run("should return all displays", func(t *testing.T) { + displays, _ := glfw.Displays(mainThreadLoop) + // when + all := displays.All() + // then + assert.NotEmpty(t, all) + }) +} + +func TestDisplaysAPI_Primary(t *testing.T) { + t.Run("should return primary display", func(t *testing.T) { + displays, _ := glfw.Displays(mainThreadLoop) + primary, ok := displays.Primary() + require.True(t, ok) + assert.NotNil(t, primary) + }) +} + +func TestDisplay_Name(t *testing.T) { + t.Run("should return display's name", func(t *testing.T) { + displays, _ := glfw.Displays(mainThreadLoop) + display, _ := displays.Primary() + // when + name := display.Name() + // then + assert.NotEmpty(t, name) + }) +} + +func TestDisplay_Workarea(t *testing.T) { + t.Run("should return display's workarea", func(t *testing.T) { + displays, _ := glfw.Displays(mainThreadLoop) + display, _ := displays.Primary() + // when + workarea := display.Workarea() + assert.True(t, workarea.X() >= 0) + assert.True(t, workarea.Y() >= 0) + assert.True(t, workarea.Width() > 0) + assert.True(t, workarea.Height() > 0) + }) +} + +func TestDisplay_VideoMode(t *testing.T) { + t.Run("should return current video mode for display", func(t *testing.T) { + displays, _ := glfw.Displays(mainThreadLoop) + display, _ := displays.Primary() + // when + mode := display.VideoMode() + // then + assert.True(t, mode.Width() > 0) + assert.True(t, mode.Height() > 0) + assert.True(t, mode.RefreshRate() >= 0) + }) +} + +func TestDisplay_PhysicalSize(t *testing.T) { + t.Run("should return physical size for display", func(t *testing.T) { + displays, _ := glfw.Displays(mainThreadLoop) + display, _ := displays.Primary() + // when + size := display.PhysicalSize() + // then + assert.True(t, size.Width() > 0) + assert.True(t, size.Height() > 0) + }) +} + +func TestDisplay_VideoModes(t *testing.T) { + t.Run("should return all vide modes for display", func(t *testing.T) { + displays, _ := glfw.Displays(mainThreadLoop) + display, _ := displays.Primary() + // when + modes := display.VideoModes() + // then + assert.True(t, len(modes) > 0) + // and + for _, mode := range modes { + assert.True(t, mode.Width() > 0) + assert.True(t, mode.Height() > 0) + assert.True(t, mode.RefreshRate() >= 0) + } + }) +} diff --git a/glfw/glfw.go b/glfw/glfw.go index 2de45ce8..0a5be486 100644 --- a/glfw/glfw.go +++ b/glfw/glfw.go @@ -214,6 +214,32 @@ func (g *OpenGL) OpenWindow(width, height int, options ...WindowOption) (*Window return win, nil } +// OpenFullScreenWindow creates and shows Window in fullscreen mode +// +// Please note that on MacOS X the resulting window may have +// a different size than the resolution from the given video mode. +func (g *OpenGL) OpenFullScreenWindow(mode VideoMode, options ...WindowOption) (*Window, error) { + window, err := g.OpenWindow(0, 0, options...) + if err != nil { + return nil, err + } + done := make(chan bool) + g.mainThreadLoop.Execute(func() { + window.fullScreenMode = &mode + window.sizeBefore = &sizeBeforeEnteringFullScreen{ + x: 0, + y: 0, + width: mode.Width() / window.Zoom(), + height: mode.Height() / window.Zoom(), + zoom: window.Zoom(), + } + // monitor can be set only after window is shown + window.setMonitor(done, mode.monitor, 0, 0, mode.Width(), mode.Height(), mode.RefreshRate()) + }) + <-done + return window, err +} + // Context returns OpenGL's context. It's methods can be invoked from any goroutine. // Each invocation will return the same instance. func (g *OpenGL) Context() *gl.Context { @@ -254,6 +280,34 @@ func Zoom(zoom int) WindowOption { } } +// Resizable makes window resizable by the user. +// +// To get the information about current window size please use Window.Width() and Window.Height() +func Resizable(resizable bool) WindowOption { + return func(window *Window) { + window.setBoolAttrib(glfw.Resizable, resizable) + } +} + +// NoAutoIconifyHint is Window hint which does not iconify full screen windows on focus loss +func NoAutoIconifyHint() WindowOption { + return func(window *Window) { + window.setBoolAttrib(glfw.AutoIconify, false) + } +} + +// Position sets the position, in pixels, of the upper-left corner +// of the client area of the window. +// +// To get the information about current window position please use Window.X() and Window.Y() +func Position(x, y int) WindowOption { + return func(window *Window) { + window.glfwWindow.Hide() + window.glfwWindow.SetPos(x, y) + window.glfwWindow.Show() + } +} + // NewCursor creates a new custom cursor look that can be set for a Window with SetCursor. // The look is taken from a Selection. The size of the cursor is based on the Selection size // and zoom. diff --git a/glfw/glfw_test.go b/glfw/glfw_test.go index 3f7f1853..ac9503ec 100644 --- a/glfw/glfw_test.go +++ b/glfw/glfw_test.go @@ -1,9 +1,11 @@ package glfw_test import ( + "math" "os" "sync" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -341,6 +343,134 @@ func TestOpenGL_OpenWindow(t *testing.T) { }) } }) + + t.Run("should open window on a given position", func(t *testing.T) { + openGL, _ := glfw.NewOpenGL(mainThreadLoop) + defer openGL.Destroy() + x := 100 + y := 200 + // when + window, err := openGL.OpenWindow(640, 360, glfw.Position(x, y)) + require.NoError(t, err) + defer window.Close() + // then + assert.Eventually(t, func() bool { + return x == window.X() && + y == window.Y() + }, 1*time.Second, 10*time.Millisecond) + }) + + t.Run("should set NoDecorationHint", func(t *testing.T) { + openGL, _ := glfw.NewOpenGL(mainThreadLoop) + defer openGL.Destroy() + // when + window, err := openGL.OpenWindow(640, 360, glfw.NoDecorationHint()) + require.NoError(t, err) + defer window.Close() + // then + assert.False(t, window.Decorated()) + }) + + t.Run("should open in a full screen and zoom 1", func(t *testing.T) { + openGL, _ := glfw.NewOpenGL(mainThreadLoop) + defer openGL.Destroy() + displays, _ := glfw.Displays(mainThreadLoop) + display, _ := displays.Primary() + videoMode := display.VideoModes()[0] + // when + window, err := openGL.OpenFullScreenWindow(videoMode) + require.NoError(t, err) + defer window.Close() + // then + assert.Eventually(t, func() bool { + return videoMode.Width() == window.Width() && + videoMode.Height() == window.Height() && + videoMode.Width() == window.Screen().Width() && + videoMode.Height() == window.Screen().Height() + }, time.Second, 10*time.Millisecond) + }) + + t.Run("should open in a full screen and zoom 2", func(t *testing.T) { + openGL, _ := glfw.NewOpenGL(mainThreadLoop) + defer openGL.Destroy() + displays, _ := glfw.Displays(mainThreadLoop) + display, _ := displays.Primary() + videoMode := display.VideoModes()[0] + // when + window, err := openGL.OpenFullScreenWindow(videoMode, glfw.Zoom(2)) + // then + require.NoError(t, err) + defer window.Close() + assert.Eventually(t, func() bool { + return videoMode.Width() == window.Width() && + videoMode.Height() == window.Height() && + videoMode.Width()/2 == window.Screen().Width() && + videoMode.Height()/2 == window.Screen().Height() + }, time.Second, 10*time.Millisecond) + }) + + t.Run("should open in a full screen with partially visible right pixels", func(t *testing.T) { + openGL, _ := glfw.NewOpenGL(mainThreadLoop) + defer openGL.Destroy() + var ( + displays, _ = glfw.Displays(mainThreadLoop) + display, _ = displays.Primary() + videoMode = display.VideoModes()[0] // TODO avoid using 1:1, 2:1, 1:2 ratios + zoom = videoMode.Height() / 4 + expectedWidth = int(math.Ceil(float64(videoMode.Width()) / float64(zoom))) + expectedHeight = 4 + ) + // when + window, err := openGL.OpenFullScreenWindow(videoMode, glfw.Zoom(zoom)) + // then + require.NoError(t, err) + defer window.Close() + assert.Eventually(t, func() bool { + return videoMode.Width() == window.Width() && + videoMode.Height() == window.Height() && + expectedWidth == window.Screen().Width() && + expectedHeight == window.Screen().Height() + }, time.Second, 10*time.Millisecond) + }) + + t.Run("should open in a full screen with partially visible bottom pixels", func(t *testing.T) { + openGL, _ := glfw.NewOpenGL(mainThreadLoop) + defer openGL.Destroy() + var ( + displays, _ = glfw.Displays(mainThreadLoop) + display, _ = displays.Primary() + videoMode = display.VideoModes()[0] // TODO avoid using 1:1, 2:1, 1:2 ratios + zoom = videoMode.Width() / 4 + expectedWidth = 4 + expectedHeight = int(math.Ceil(float64(videoMode.Height()) / float64(zoom))) + ) + // when + window, err := openGL.OpenFullScreenWindow(videoMode, glfw.Zoom(zoom)) + // then + require.NoError(t, err) + defer window.Close() + assert.Eventually(t, func() bool { + return videoMode.Width() == window.Width() && + videoMode.Height() == window.Height() && + expectedWidth == window.Screen().Width() && + expectedHeight == window.Screen().Height() + }, time.Second, 10*time.Millisecond) + }) + + t.Run("should open full screen window with no auto iconify", func(t *testing.T) { + openGL, _ := glfw.NewOpenGL(mainThreadLoop) + defer openGL.Destroy() + displays, _ := glfw.Displays(mainThreadLoop) + display, _ := displays.Primary() + videoMode := display.VideoModes()[0] + // when + window, err := openGL.OpenFullScreenWindow(videoMode, glfw.NoAutoIconifyHint()) + // then + require.NoError(t, err) + defer window.Close() + assert.False(t, window.AutoIconify()) + }) + } func TestWindow_Width(t *testing.T) { diff --git a/glfw/polygons.go b/glfw/polygons.go index 63b54cf7..1e02d455 100644 --- a/glfw/polygons.go +++ b/glfw/polygons.go @@ -17,7 +17,7 @@ func newScreenPolygon(context *gl.Context) *screenPolygon { 1, -1, 1, 0, -1, -1, 0, 0, } - buffer := context.NewFloatVertexBuffer(len(data), gl.StaticDraw) + buffer := context.NewFloatVertexBuffer(len(data), gl.DynamicDraw) buffer.Upload(0, data) vao := context.NewVertexArray(gl.VertexLayout{gl.Vec2, gl.Vec2}) @@ -31,16 +31,21 @@ func newScreenPolygon(context *gl.Context) *screenPolygon { Offset: 2, Stride: 4, }) - return &screenPolygon{vao: vao, vbo: buffer, api: context.API()} + return &screenPolygon{vao: vao, vbo: buffer, rect: data, api: context.API()} } type screenPolygon struct { - vao *gl.VertexArray - vbo *gl.FloatVertexBuffer - api gl.API + vao *gl.VertexArray + vbo *gl.FloatVertexBuffer + rect rect + api gl.API } -func (p *screenPolygon) draw() { +func (p *screenPolygon) draw(xRight, yBottom float32) { + p.rect.SetTopRight(xRight, 1) + p.rect.SetBottomRight(xRight, yBottom) + p.rect.SetBottomLeft(-1, yBottom) + p.vbo.Upload(0, p.rect) p.api.BindVertexArray(p.vao.ID()) p.api.DrawArrays(gl33.TRIANGLE_FAN, 0, 4) } @@ -49,3 +54,20 @@ func (p *screenPolygon) delete() { p.vao.Delete() p.vbo.Delete() } + +type rect []float32 + +func (r rect) SetTopRight(x, y float32) { + r[4] = x + r[5] = y +} + +func (r rect) SetBottomRight(x, y float32) { + r[8] = x + r[9] = y +} + +func (r rect) SetBottomLeft(x, y float32) { + r[12] = x + r[13] = y +} diff --git a/glfw/window.go b/glfw/window.go index e5f63fb8..f2e2be4c 100644 --- a/glfw/window.go +++ b/glfw/window.go @@ -1,7 +1,9 @@ package glfw import ( + "errors" "log" + "time" gl33 "github.com/go-gl/gl/v3.3-core/gl" "github.com/go-gl/glfw/v3.3/glfw" @@ -31,6 +33,12 @@ type Window struct { mouseWindow *mouseWindow onClose func(*Window) closed bool + fullScreenMode *VideoMode + sizeBefore *sizeBeforeEnteringFullScreen +} + +type sizeBeforeEnteringFullScreen struct { + x, y, width, height, zoom int } func newWindow(glfwWindow *glfw.Window, mainThreadLoop *MainThreadLoop, width, height int, context *gl.Context, sharedContext *gl.Context, onClose func(*Window), options ...WindowOption) (*Window, error) { @@ -84,9 +92,23 @@ func newWindow(glfwWindow *glfw.Window, mainThreadLoop *MainThreadLoop, width, h win.glfwWindow.Show() }) <-sizeIsSet + mainThreadLoop.Execute(func() { + win.glfwWindow.SetSizeCallback(win.sizeCallback) + }) return win, nil } +func (w *Window) sizeCallback(_ *glfw.Window, width int, height int) { + w.requestedWidth = width / w.zoom + w.requestedHeight = height / w.zoom + if width%w.zoom != 0 { + w.requestedWidth++ + } + if height%w.zoom != 0 { + w.requestedHeight++ + } +} + func updateSize(win *Window) <-chan bool { done := make(chan bool) newWidth := win.requestedWidth * win.zoom @@ -145,7 +167,9 @@ func (w *Window) DrawIntoBackBuffer() { api.Viewport(0, 0, int32(width), int32(height)) api.BindTexture(gl33.TEXTURE_2D, w.screenAcceleratedImage.TextureID()) api.UseProgram(w.program.ID()) - w.screenPolygon.draw() + xRight := float32(2*w.screenImage.Width()*w.zoom)/float32(width) - 1 + yBottom := -(float32(2*w.screenImage.Height()*w.zoom) / float32(height)) + 1 + w.screenPolygon.draw(xRight, yBottom) } // SwapBuffers makes current back buffer visible to the user. @@ -162,6 +186,7 @@ func (w *Window) Close() { return } w.mainThreadLoop.Execute(func() { + w.glfwWindow.SetSizeCallback(nil) w.glfwWindow.SetKeyCallback(nil) w.glfwWindow.SetMouseButtonCallback(nil) w.glfwWindow.SetScrollCallback(nil) @@ -187,17 +212,39 @@ func (w *Window) ShouldClose() bool { // Width returns the actual width of the window in pixels. It may be different // than requested width used when window was open due to platform limitation. // If zooming is used the width is multiplied by zoom. -func (w *Window) Width() int { - width, _ := w.mouseWindow.Size() - return width +func (w *Window) Width() (width int) { + w.mainThreadLoop.Execute(func() { + width, _ = w.mouseWindow.Size() + }) + return } // Height returns the actual height of the window in pixels. It may be different // than requested height used when window was open due to platform limitation. // If zooming is used the height is multiplied by zoom. -func (w *Window) Height() int { - _, height := w.mouseWindow.Size() - return height +func (w *Window) Height() (height int) { + w.mainThreadLoop.Execute(func() { + _, height = w.mouseWindow.Size() + }) + return +} + +// X returns the X coordinate, in pixels, of the upper-left +// corner of the client area of the window. +func (w *Window) X() (x int) { + w.mainThreadLoop.Execute(func() { + x, _ = w.glfwWindow.GetPos() + }) + return +} + +// Y returns the Y coordinate, in pixels, of the upper-left +// corner of the client area of the window. +func (w *Window) Y() (y int) { + w.mainThreadLoop.Execute(func() { + _, y = w.glfwWindow.GetPos() + }) + return } // Zoom returns the actual zoom. It is the zoom given during opening the window, @@ -217,6 +264,12 @@ func (w *Window) PollKeyboardEvent() (event keyboard.Event, ok bool) { // Screen returns the image.Selection for the whole Window image func (w *Window) Screen() image.Selection { + var width, height int + w.mainThreadLoop.Execute(func() { + width = w.requestedWidth + height = w.requestedHeight + }) + w.ensureScreenSize(width, height) return w.screenImage.WholeImageSelection() } @@ -240,3 +293,158 @@ func (w *Window) SetCursor(cursor *Cursor) { func (w *Window) Title() string { return w.title } + +// Resize changes the size of the window. Works only if full screen is off. +// +// Please note that retained Screen instance became obsolete after Resize. +// You have to call Window.Screen() again to get new screen +func (w *Window) Resize(width int, height, zoom int) error { + done := make(chan bool) + w.mainThreadLoop.Execute(func() { + if w.fullScreenMode != nil { + return + } + if zoom < 1 { + zoom = 1 + } + w.zoom = zoom + newWidth := width * w.zoom + newHeight := height * w.zoom + w.glfwWindow.SetSize(newWidth, newHeight) + w.glfwWindow.SetSizeCallback(func(ww *glfw.Window, width int, height int) { + close(done) + w.sizeCallback(ww, width, height) + w.glfwWindow.SetSizeCallback(w.sizeCallback) + }) + }) + select { + case <-done: + return nil + case <-time.After(1 * time.Second): + return errors.New("timeout when waiting for window to resize") + } +} + +func (w *Window) ensureScreenSize(width int, height int) { + if w.screenImage.Width() != width || w.screenImage.Height() != height { + newAcceleratedImage := w.sharedContext.NewAcceleratedImage(width, height) + newImage := image.New(newAcceleratedImage) + newSelection := newImage.WholeImageSelection() + oldSelection := w.screenImage.WholeImageSelection() + for y := 0; y < newImage.Height(); y++ { + for x := 0; x < newImage.Width(); x++ { + newSelection.SetColor(x, y, oldSelection.Color(x, y)) + } + } + w.screenImage.Delete() + w.screenAcceleratedImage = newAcceleratedImage + w.screenImage = newImage + } +} + +// SetPosition sets the position, in pixels, of the upper-left corner +// of the client area of the window. +func (w *Window) SetPosition(x int, y int) { + w.mainThreadLoop.Execute(func() { + w.glfwWindow.SetPos(x, y) + }) +} + +// SetDecorationHint specifies whether the window will have window decorations +// such as a border, a close widget, etc. +func (w *Window) SetDecorationHint(enabled bool) { + w.mainThreadLoop.Execute(func() { + w.setBoolAttrib(glfw.Decorated, enabled) + }) +} + +// Decorated returns true if window has decorations such as a border, a close widget, etc. +func (w *Window) Decorated() (decorated bool) { + w.mainThreadLoop.Execute(func() { + decorated = w.boolAttrib(glfw.Decorated) + }) + return +} + +func (w *Window) setBoolAttrib(hint glfw.Hint, enabled bool) { + if enabled { + w.glfwWindow.SetAttrib(hint, glfw.True) + } else { + w.glfwWindow.SetAttrib(hint, glfw.False) + } +} + +func (w *Window) boolAttrib(hint glfw.Hint) bool { + return w.glfwWindow.GetAttrib(hint) == glfw.True +} + +// EnterFullScreen makes window full screen using given display video mode +func (w *Window) EnterFullScreen(mode VideoMode, zoom int) { + w.sizeBefore = &sizeBeforeEnteringFullScreen{ + x: w.X(), + y: w.Y(), + width: w.requestedWidth, + height: w.requestedHeight, + zoom: w.zoom, + } + w.zoom = zoom + done := make(chan bool) + w.mainThreadLoop.Execute(func() { + w.fullScreenMode = &mode + w.setMonitor(done, mode.monitor, 0, 0, mode.Width(), mode.Height(), mode.RefreshRate()) + }) + <-done +} + +// ExitFullScreen exits from full screen and resizes the window to previous size +func (w *Window) ExitFullScreen() { + x := w.sizeBefore.x + y := w.sizeBefore.y + width := w.sizeBefore.width + height := w.sizeBefore.height + zoom := w.sizeBefore.zoom + w.ExitFullScreenUsing(x, y, width, height, zoom) +} + +// ExitFullScreenUsing exits from full screen and resizes the window +func (w *Window) ExitFullScreenUsing(x, y, width, height, zoom int) { + done := make(chan bool) + w.mainThreadLoop.Execute(func() { + w.fullScreenMode = nil + w.requestedWidth = width + w.requestedHeight = height + w.zoom = zoom + w.setMonitor(done, nil, x, y, width*zoom, height*zoom, 0) + }) + <-done +} + +func (w *Window) setMonitor(done chan bool, monitor *glfw.Monitor, xpos, ypos, width, height, refreshRate int) { + w.glfwWindow.SetMonitor(monitor, xpos, ypos, width, height, refreshRate) + if w.requestedWidth*w.zoom == width && w.requestedHeight*w.zoom == height { + close(done) + return + } + w.glfwWindow.SetSizeCallback(func(ww *glfw.Window, width int, height int) { + close(done) + w.sizeCallback(ww, width, height) + w.glfwWindow.SetSizeCallback(w.sizeCallback) + }) +} + +// SetAutoIconifyHint specifies whether fullscreen windows automatically iconify +// (and restore the previous video mode) on focus loss. +func (w *Window) SetAutoIconifyHint(enabled bool) { + w.mainThreadLoop.Execute(func() { + w.setBoolAttrib(glfw.AutoIconify, enabled) + }) +} + +// AutoIconify returns true when fullscreen windows automatically iconify +// (and restore the previous video mode) on focus loss. +func (w *Window) AutoIconify() (enabled bool) { + w.mainThreadLoop.Execute(func() { + enabled = w.boolAttrib(glfw.AutoIconify) + }) + return +} diff --git a/glfw/window_test.go b/glfw/window_test.go index 984d1903..1bbddf63 100644 --- a/glfw/window_test.go +++ b/glfw/window_test.go @@ -3,6 +3,7 @@ package glfw_test import ( "fmt" "testing" + "time" "github.com/go-gl/gl/v3.3-core/gl" "github.com/stretchr/testify/assert" @@ -240,6 +241,37 @@ func TestWindow_DrawIntoBackBuffer(t *testing.T) { }) }) + t.Run("should draw perfect pixels when window size is not a zoom multiplication", func(t *testing.T) { + displays, _ := glfw.Displays(mainThreadLoop) + display, _ := displays.Primary() + videoMode := display.VideoModes()[0] // TODO avoid using 1:1, 2:1, 1:2 ratios + zoom := videoMode.Height() / 2 + openGL, _ := glfw.NewOpenGL(mainThreadLoop) + defer openGL.Destroy() + window, err := openGL.OpenFullScreenWindow(videoMode, glfw.Zoom(zoom)) + require.NoError(t, err) + defer window.Close() + c00 := image.RGBA(10, 20, 30, 40) + c10 := image.RGBA(50, 60, 70, 80) + c01 := image.RGBA(90, 100, 110, 120) + c11 := image.RGBA(130, 140, 150, 160) + window.Screen().SetColor(0, 0, c00) + window.Screen().SetColor(1, 0, c10) + window.Screen().SetColor(0, 1, c01) + window.Screen().SetColor(1, 1, c11) + // when + window.DrawIntoBackBuffer() // TODO This does not work because size was not yet updated + // then + fb := framebufferPixels(window.ContextAPI(), 0, 0, 1, 1) + assert.Equal(t, c01, fb[0]) + fb = framebufferPixels(window.ContextAPI(), int32(videoMode.Width()/2), 0, 1, 1) + assert.Equal(t, c11, fb[0]) + fb = framebufferPixels(window.ContextAPI(), 0, int32(videoMode.Height()/2), 1, 1) + assert.Equal(t, c00, fb[0]) + fb = framebufferPixels(window.ContextAPI(), int32(videoMode.Width()/2), int32(videoMode.Height()/2), 1, 1) + assert.Equal(t, c10, fb[0]) + }) + } func TestWindow_Draw(t *testing.T) { @@ -322,7 +354,7 @@ func windowOfColor(openGL *glfw.OpenGL, color image.Color) (*glfw.Window, error) } func framebufferPixels(context gl2.API, x, y, width, height int32) []image.Color { - size := (height - y) * (width - x) + size := height * width frameBuffer := make([]image.Color, size) context.ReadPixels(x, y, width, height, gl.RGBA, gl.UNSIGNED_BYTE, gl.Ptr(frameBuffer)) return frameBuffer @@ -527,3 +559,288 @@ func TestWindow_SetCursor(t *testing.T) { win.SetCursor(cursor) }) } + +func TestWindow_Resize(t *testing.T) { + openGL, _ := glfw.NewOpenGL(mainThreadLoop) + defer openGL.Destroy() + + t.Run("should eventually resize window", func(t *testing.T) { + tests := map[string]struct { + newWidth, newHeight, newZoom int + expectedWidth, expectedHeight, expectedZoom int + }{ + "320x180x1": { + newWidth: 320, + newHeight: 180, + newZoom: 1, + expectedWidth: 320, + expectedHeight: 180, + expectedZoom: 1, + }, + "640x360x2": { + newWidth: 640, + newHeight: 360, + newZoom: 2, + expectedWidth: 1280, + expectedHeight: 720, + expectedZoom: 2, + }, + "320x180x0": { + newWidth: 320, + newHeight: 180, + newZoom: 0, + expectedWidth: 320, + expectedHeight: 180, + expectedZoom: 1, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + window, _ := openGL.OpenWindow(640, 360) + defer window.Close() + // when + window.Resize(test.newWidth, test.newHeight, test.newZoom) + // then + assert.Eventually(t, func() bool { + return window.Width() == test.expectedWidth && + window.Height() == test.expectedHeight && + window.Zoom() == test.expectedZoom + }, 1*time.Second, 10*time.Millisecond) + }) + } + }) + + t.Run("should resize screen", func(t *testing.T) { + originalWidth, originalHeight := 640, 320 + tests := map[string]struct { + newWidth, newHeight, newZoom int + }{ + "nothing has changed": { + newWidth: originalWidth, + newHeight: originalHeight, + newZoom: 1, + }, + "zoom changed to 2": { + newWidth: originalWidth, + newHeight: originalHeight, + newZoom: 2, + }, + "zoom changed to 0": { + newWidth: originalWidth, + newHeight: originalHeight, + newZoom: 0, + }, + "half the size": { + newWidth: originalWidth / 2, + newHeight: originalHeight / 2, + newZoom: 1, + }, + "double the size": { + newWidth: originalWidth * 2, + newHeight: originalHeight * 2, + newZoom: 1, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + window, _ := openGL.OpenWindow(originalWidth, originalHeight) + defer window.Close() + fillWithColors(window.Screen()) + originalImage := openGL.NewImage(originalWidth, originalHeight) + defer originalImage.Delete() + originalSelection := originalImage.WholeImageSelection() + copySourceTo(window.Screen(), originalSelection) + // when + window.Resize(test.newWidth, test.newHeight, test.newZoom) + // then + resizedScreen := window.Screen() + assert.Equal(t, test.newWidth, resizedScreen.Width()) + assert.Equal(t, test.newHeight, resizedScreen.Height()) + // and + assertSelectionEqual(t, originalSelection, resizedScreen) + }) + } + + }) +} + +func assertSelectionEqual(t *testing.T, expected image.Selection, actual image.Selection) { + for y := 0; y < actual.Height(); y++ { + for x := 0; x < actual.Width(); x++ { + assert.Equal(t, expected.Color(x, y), actual.Color(x, y)) + } + } +} + +func copySourceTo(source image.Selection, target image.Selection) { + for y := 0; y < source.Height(); y++ { + for x := 0; x < source.Width(); x++ { + target.SetColor(x, y, source.Color(x, y)) + } + } +} + +func fillWithColors(selection image.Selection) { + r, g, b, a := 10, 20, 30, 40 + for y := 0; y < selection.Height(); y++ { + for x := 0; x < selection.Width(); x++ { + selection.SetColor(x, y, image.RGBAi(r, g, b, a)) + if r++; r > 255 { + r = 0 + } + if g++; g > 255 { + g = 0 + } + if b++; b > 255 { + b = 0 + } + if a++; a > 255 { + a = 0 + } + } + } +} + +func TestWindow_SetPosition(t *testing.T) { + openGL, _ := glfw.NewOpenGL(mainThreadLoop) + defer openGL.Destroy() + + t.Run("should eventually set position of window", func(t *testing.T) { + window, _ := openGL.OpenWindow(640, 360) + defer window.Close() + // when + newX := 100 + newY := 200 + window.SetPosition(newX, newY) + // then + assert.Eventually(t, func() bool { + return newX == window.X() && + newY == window.Y() + }, 1*time.Second, 10*time.Millisecond) + }) +} + +func TestWindow_SetDecorationHint(t *testing.T) { + openGL, _ := glfw.NewOpenGL(mainThreadLoop) + defer openGL.Destroy() + + t.Run("should hide decorations", func(t *testing.T) { + window, _ := openGL.OpenWindow(640, 360) + defer window.Close() + // when + window.SetDecorationHint(true) + // then + assert.True(t, window.Decorated()) + }) + + t.Run("should show decorations", func(t *testing.T) { + window, _ := openGL.OpenWindow(640, 360) + defer window.Close() + // when + window.SetDecorationHint(false) + // then + assert.False(t, window.Decorated()) + }) +} + +func TestWindow_EnterFullScreen(t *testing.T) { + openGL, _ := glfw.NewOpenGL(mainThreadLoop) + defer openGL.Destroy() + + t.Run("should enter full screen using first video mode", func(t *testing.T) { + displays, _ := glfw.Displays(mainThreadLoop) + display, _ := displays.Primary() + // current video mode on MacOS is returning not supported full screen video mode + videoMode := display.VideoModes()[0] + window, _ := openGL.OpenWindow(320, 200) + defer window.Close() + // when + window.EnterFullScreen(videoMode, 2) + // then + var ( + expectedWindowWidth = videoMode.Width() + expectedWindowHeight = videoMode.Height() + expectedScreenWidth = expectedWindowWidth / 2 + expectedScreenHeight = expectedWindowHeight / 2 + ) + assert.Eventually(t, func() bool { + screen := window.Screen() + return expectedWindowWidth == window.Width() && + expectedWindowHeight == window.Height() && + expectedScreenWidth == screen.Width() && + expectedScreenHeight == screen.Height() + }, 1*time.Second, 10*time.Millisecond) + }) +} + +func TestWindow_ExitFullScreen(t *testing.T) { + openGL, _ := glfw.NewOpenGL(mainThreadLoop) + defer openGL.Destroy() + + t.Run("should exit full screen", func(t *testing.T) { + displays, _ := glfw.Displays(mainThreadLoop) + display, _ := displays.Primary() + videoMode := display.VideoMode() + window, _ := openGL.OpenFullScreenWindow(videoMode, glfw.Zoom(2)) + defer window.Close() + // when + window.ExitFullScreen() + // then + assert.Eventually(t, func() bool { + return videoMode.Width() == window.Width() && + videoMode.Height() == window.Height() && + 2 == window.Zoom() && + videoMode.Width()/2 == window.Screen().Width() && + videoMode.Height()/2 == window.Screen().Height() + }, 1*time.Second, 10*time.Millisecond) + }) + + t.Run("should exit full screen after executing EnterFullScreen", func(t *testing.T) { + displays, _ := glfw.Displays(mainThreadLoop) + display, _ := displays.Primary() + videoMode := display.VideoMode() + window, _ := openGL.OpenWindow(320, 200, glfw.Zoom(2)) + defer window.Close() + x, y := window.X(), window.Y() + window.EnterFullScreen(videoMode, 1) + // when + window.ExitFullScreen() + // then + assert.Eventually(t, func() bool { + return 640 == window.Width() && + 400 == window.Height() && + 2 == window.Zoom() && + x == window.X() && + y == window.Y() && + 320 == window.Screen().Width() && + 200 == window.Screen().Height() + }, 1*time.Second, 10*time.Millisecond) + }) +} + +func TestWindow_SetAutoIconifyHint(t *testing.T) { + openGL, _ := glfw.NewOpenGL(mainThreadLoop) + defer openGL.Destroy() + displays, _ := glfw.Displays(mainThreadLoop) + display, _ := displays.Primary() + videoMode := display.VideoMode() + + t.Run("should no iconify full screen window on focus lost", func(t *testing.T) { + window, _ := openGL.OpenFullScreenWindow(videoMode) + defer window.Close() + // when + window.SetAutoIconifyHint(false) + // then + assert.False(t, window.AutoIconify()) + }) + + t.Run("should iconify full screen window on focus lost", func(t *testing.T) { + window, _ := openGL.OpenFullScreenWindow(videoMode) + defer window.Close() + // when + window.SetAutoIconifyHint(true) + // then + assert.True(t, window.AutoIconify()) + }) + +}