commit b832628da8a8e9bf5472dd2f630df9dbb4e62b9d Author: milarin Date: Sun Jan 22 12:38:03 2023 +0100 initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c454cc --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +old +*.otf diff --git a/app.go b/app.go new file mode 100644 index 0000000..1480a82 --- /dev/null +++ b/app.go @@ -0,0 +1,92 @@ +package gui + +import ( + "image/color" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/ebitenutil" + "github.com/hajimehoshi/ebiten/v2/examples/resources/fonts" + + _ "embed" +) + +type App struct { + Root View + Background color.Color + + defaultCtx *appCtx + + oldMouseEvent MouseEvent +} + +func NewApp(root View) (*App, error) { + fnt, err := LoadFontFromBytes(fonts.MPlus1pRegular_ttf) + if err != nil { + return nil, err + } + + return &App{ + Root: root, + Background: color.White, + + defaultCtx: &appCtx{ + font: fnt, + fontSize: 16, + fg: color.Black, + bg: color.Transparent, + }, + }, nil +} + +func (a *App) Update() error { + if a.Root == nil { + return nil + } + + winw, winh := ebiten.WindowSize() + window := D(0, 0, winw, winh) + + // handle mouse events + mouseEvent := makeMouseEvent() + if mouseEvent.IsScrollEvent() { + a.Root.OnMouseScroll(mouseEvent) + } + if mouseEvent.HasMouseMoved(a.oldMouseEvent) && mouseEvent.Position.In(window) { + a.Root.OnMouseMove(mouseEvent) + } + if mouseEvent.Buttons.anyClicked() { + a.Root.OnMouseClick(mouseEvent) + } + a.oldMouseEvent = mouseEvent + + return nil +} + +func (a *App) Draw(screen *ebiten.Image) { + if a.Root == nil { + return + } + + // layout phase + a.Root.Layout(a.defaultCtx) + + // draw phase + screen.Fill(a.Background) + a.Root.Draw(newImage(screen), a.defaultCtx) + + // show fps + ebitenutil.DebugPrint(screen, "") //fmt.Sprintf("fps: %g", ebiten.CurrentFPS())) +} + +func (a *App) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) { + return outsideWidth, outsideHeight +} + +func (a *App) Start() error { + ebiten.SetScreenTransparent(true) + ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled) + //ebiten.SetFPSMode(ebiten.FPSModeVsyncOffMinimum) + ebiten.SetFPSMode(ebiten.FPSModeVsyncOffMaximum) + ebiten.SetRunnableOnUnfocused(true) + return ebiten.RunGame(a) +} diff --git a/ctx.go b/ctx.go new file mode 100644 index 0000000..ed28e6c --- /dev/null +++ b/ctx.go @@ -0,0 +1,46 @@ +package gui + +import ( + "image" + "image/color" + + "github.com/hajimehoshi/ebiten/text" + "golang.org/x/image/font" +) + +type AppContext interface { + Font() *Font + FontSize() int + Foreground() color.Color + Background() color.Color + + MeasureString(str string, f font.Face) image.Rectangle +} + +type appCtx struct { + font *Font + fontSize int + fg, bg color.Color +} + +var _ AppContext = &appCtx{} + +func (c *appCtx) Font() *Font { + return c.font +} + +func (c *appCtx) FontSize() int { + return c.fontSize +} + +func (c *appCtx) Foreground() color.Color { + return c.fg +} + +func (c *appCtx) Background() color.Color { + return c.bg +} + +func (c *appCtx) MeasureString(str string, f font.Face) image.Rectangle { + return text.BoundString(f, str) +} diff --git a/events.go b/events.go new file mode 100644 index 0000000..c749224 --- /dev/null +++ b/events.go @@ -0,0 +1,82 @@ +package gui + +import "github.com/hajimehoshi/ebiten/v2" + +type Events interface { + OnMouseClick(event MouseEvent) (consumed bool) + OnMouseMove(event MouseEvent) (consumed bool) + OnMouseScroll(event MouseEvent) (consumed bool) +} + +type EventTmpl struct { + MouseClick func(event MouseEvent) (consumed bool) + MouseMove func(event MouseEvent) (consumed bool) + MouseScroll func(event MouseEvent) (consumed bool) +} + +var _ Events = &EventTmpl{} + +func (e *EventTmpl) OnMouseClick(event MouseEvent) (consumed bool) { + if e.MouseClick == nil { + return false + } + return e.MouseClick(event) +} + +func (e *EventTmpl) OnMouseMove(event MouseEvent) (consumed bool) { + if e.MouseMove == nil { + return false + } + return e.MouseMove(event) +} + +func (e *EventTmpl) OnMouseScroll(event MouseEvent) (consumed bool) { + if e.MouseScroll == nil { + return false + } + return e.MouseScroll(event) +} + +type MouseEvent struct { + Position Point + Buttons ButtonMask + Wheel Point +} + +func makeMouseEvent() MouseEvent { + return MouseEvent{ + Position: P(ebiten.CursorPosition()), + Buttons: Buttons(), + Wheel: Pf(ebiten.Wheel()), + } +} + +func (e MouseEvent) SubtractPos(p Point) MouseEvent { + e.Position = e.Position.Sub(p) + return e +} + +func (e MouseEvent) AddPos(p Point) MouseEvent { + e.Position = e.Position.Add(p) + return e +} + +func (e MouseEvent) IsScrollEvent() bool { + return e.Wheel.X != 0 || e.Wheel.Y != 0 +} + +func (e MouseEvent) HasMouseMoved(o MouseEvent) bool { + return e.Position != o.Position +} + +func (e MouseEvent) Pressed(button MouseButton) bool { + return e.Buttons.Pressed(button) +} + +func (e MouseEvent) JustPressed(button MouseButton) bool { + return e.Buttons.JustPressed(button) +} + +func (e MouseEvent) Clicked(button MouseButton) bool { + return e.Buttons.Clicked(button) +} diff --git a/font.go b/font.go new file mode 100644 index 0000000..775207a --- /dev/null +++ b/font.go @@ -0,0 +1,75 @@ +package gui + +import ( + "bytes" + "io" + "os" + + "golang.org/x/image/font" + "golang.org/x/image/font/opentype" + "golang.org/x/image/font/sfnt" +) + +type Font struct { + font *sfnt.Font + faces map[int]font.Face +} + +func LoadOpenTypeFont(otfFile string) (*Font, error) { + ff, err := os.Open(otfFile) + if err != nil { + return nil, err + } + return LoadFontFromReaderAt(ff) +} + +func LoadFontFromBytes(data []byte) (*Font, error) { + return LoadFontFromReaderAt(bytes.NewReader(data)) +} + +func LoadFontFromReaderAt(r io.ReaderAt) (*Font, error) { + fnt, err := opentype.ParseReaderAt(r) + if err != nil { + return nil, err + } + + return &Font{ + font: fnt, + faces: map[int]font.Face{}, + }, nil +} + +func (f *Font) FaceErr(fontSize int) (face font.Face, err error) { + if v, ok := f.faces[fontSize]; ok { + return v, nil + } + + face, err = opentype.NewFace(f.font, &opentype.FaceOptions{ + Size: float64(fontSize), + DPI: 72, + Hinting: font.HintingFull, + }) + + if err == nil { + f.faces[fontSize] = face + } + + return face, err +} + +func (f *Font) Face(fontSize int) font.Face { + face, err := f.FaceErr(fontSize) + if err != nil { + panic(err) + } + return face +} + +func (f *Font) Close() error { + for _, face := range f.faces { + if err := face.Close(); err != nil { + return err + } + } + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cc688b8 --- /dev/null +++ b/go.mod @@ -0,0 +1,20 @@ +module git.milar.in/milarin/gui + +go 1.18 + +require ( + github.com/hajimehoshi/ebiten v1.12.12 + github.com/hajimehoshi/ebiten/v2 v2.3.1 + golang.org/x/image v0.0.0-20220413100746-70e8d0d3baa9 +) + +require ( + github.com/go-gl/glfw/v3.3/glfw v0.0.0-20220320163800-277f93cfa958 // indirect + github.com/gofrs/flock v0.8.1 // indirect + github.com/jezek/xgb v1.0.0 // indirect + golang.org/x/exp v0.0.0-20210722180016-6781d3edade3 // indirect + golang.org/x/mobile v0.0.0-20220325161704-447654d348e3 // indirect + golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect + golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f // indirect + golang.org/x/text v0.3.7 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e62f30a --- /dev/null +++ b/go.sum @@ -0,0 +1,114 @@ +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200707082815-5321531c36a2/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20220320163800-277f93cfa958 h1:TL70PMkdPCt9cRhKTqsm+giRpgrd0IGEj763nNr2VFY= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20220320163800-277f93cfa958/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/gofrs/flock v0.8.0/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= +github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= +github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/hajimehoshi/bitmapfont v1.3.0 h1:h6+HJQ+2MKT3lEVEArjVC4/h0qcFXlVsMTGuRijEnVA= +github.com/hajimehoshi/bitmapfont v1.3.0/go.mod h1:/Qb7yVjHYNUV4JdqNkPs6BSZwLjKqkZOMIp6jZD0KgE= +github.com/hajimehoshi/bitmapfont/v2 v2.2.0 h1:E6vzlchynZj6OVohVKFqWkKW348EmDW62K5zPXDi7A8= +github.com/hajimehoshi/bitmapfont/v2 v2.2.0/go.mod h1:Llj2wTYXMuCTJEw2ATNIO6HbFPOoBYPs08qLdFAxOsQ= +github.com/hajimehoshi/ebiten v1.12.12 h1:JvmF1bXRa+t+/CcLWxrJCRsdjs2GyBYBSiFAfIqDFlI= +github.com/hajimehoshi/ebiten v1.12.12/go.mod h1:1XI25ImVCDPJiXox4h9yK/CvN5sjDYnbF4oZcFzPXHw= +github.com/hajimehoshi/ebiten/v2 v2.3.1 h1:1usmOQv+wxeBElKTYiB7UnJaIwx8+FflDfQA729uUr4= +github.com/hajimehoshi/ebiten/v2 v2.3.1/go.mod h1:MSVpCRgFyOsDA2HWNPEiBz7aOjESNulTWwD31Ud9n5c= +github.com/hajimehoshi/file2byteslice v0.0.0-20200812174855-0e5e8a80490e/go.mod h1:CqqAHp7Dk/AqQiwuhV1yT2334qbA/tFWQW0MD2dGqUE= +github.com/hajimehoshi/file2byteslice v0.0.0-20210813153925-5340248a8f41/go.mod h1:CqqAHp7Dk/AqQiwuhV1yT2334qbA/tFWQW0MD2dGqUE= +github.com/hajimehoshi/go-mp3 v0.3.1/go.mod h1:qMJj/CSDxx6CGHiZeCgbiq2DSUkbK0UbtXShQcnfyMM= +github.com/hajimehoshi/go-mp3 v0.3.3/go.mod h1:qMJj/CSDxx6CGHiZeCgbiq2DSUkbK0UbtXShQcnfyMM= +github.com/hajimehoshi/oto v0.6.1/go.mod h1:0QXGEkbuJRohbJaxr7ZQSxnju7hEhseiPx2hrh6raOI= +github.com/hajimehoshi/oto v0.6.8/go.mod h1:0QXGEkbuJRohbJaxr7ZQSxnju7hEhseiPx2hrh6raOI= +github.com/hajimehoshi/oto/v2 v2.1.0/go.mod h1:9i0oYbpJ8BhVGkXDKdXKfFthX1JUNfXjeTp944W8TGM= +github.com/jakecoffman/cp v1.0.0/go.mod h1:JjY/Fp6d8E1CHnu74gWNnU0+b9VzEdUVPoJxg2PsTQg= +github.com/jakecoffman/cp v1.1.0/go.mod h1:JjY/Fp6d8E1CHnu74gWNnU0+b9VzEdUVPoJxg2PsTQg= +github.com/jezek/xgb v1.0.0 h1:s2rRzAV8KQRlpsYA7Uyxoidv1nodMF0m6dIG6FhhVLQ= +github.com/jezek/xgb v1.0.0/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= +github.com/jfreymuth/oggvorbis v1.0.1/go.mod h1:NqS+K+UXKje0FUYUPosyQ+XTVvjmVjps1aEZH1sumIk= +github.com/jfreymuth/oggvorbis v1.0.3/go.mod h1:1U4pqWmghcoVsCJJ4fRBKv9peUJMBHixthRlBeD6uII= +github.com/jfreymuth/vorbis v1.0.0/go.mod h1:8zy3lUAm9K/rJJk223RKy6vjCZTWC61NA2QD06bfOE0= +github.com/jfreymuth/vorbis v1.0.2/go.mod h1:DoftRo4AznKnShRl1GxiTFCseHr4zR9BN3TWXyuzrqQ= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= +github.com/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA= +github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190731235908-ec7cb31e5a56/go.mod h1:JhuoJpWY28nO4Vef9tZUw9qufEGTyX1+7lmHxV5q5G4= +golang.org/x/exp v0.0.0-20210722180016-6781d3edade3 h1:IlrJD2AM5p8JhN/wVny9jt6gJ9hut2VALhSeZ3SYluk= +golang.org/x/exp v0.0.0-20210722180016-6781d3edade3/go.mod h1:DVyR6MI7P4kEQgvZJSj1fQGrWIi2RzIrfYWycwheUAc= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190703141733-d6a02ce849c9/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20200801110659-972c09e46d76/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= +golang.org/x/image v0.0.0-20220321031419-a8550c1d254a/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= +golang.org/x/image v0.0.0-20220413100746-70e8d0d3baa9 h1:LRtI4W37N+KFebI/qV0OFiLUv4GLOWeEW5hn/KEJvxE= +golang.org/x/image v0.0.0-20220413100746-70e8d0d3baa9/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mobile v0.0.0-20210208171126-f462b3930c8f/go.mod h1:skQtrUTUwhdJvXM/2KKJzY8pDgNr9I/FOMqDVRPBUS4= +golang.org/x/mobile v0.0.0-20220325161704-447654d348e3 h1:ZDL7hDvJEQEcHVkoZawKmRUgbqn1pOIzb8EinBh5csU= +golang.org/x/mobile v0.0.0-20220325161704-447654d348e3/go.mod h1:pe2sM7Uk+2Su1y7u/6Z8KJ24D7lepUjFZbhFOrmDfuQ= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191209134235-331c550502dd/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200918174421-af09f7315aff/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f h1:8w7RhxzTVgUzw/AH/9mUV5q0vMgy40SQRursCcfmkCw= +golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200117012304-6edc0a871e69/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200918232735-d647fc253266/go.mod h1:z6u4i615ZeAfBE4XtMziQW1fSVJXACjjbWkB/mvPzlU= +golang.org/x/tools v0.1.8-0.20211022200916-316ba0b74098/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= +golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/image.go b/image.go new file mode 100644 index 0000000..51224a3 --- /dev/null +++ b/image.go @@ -0,0 +1,81 @@ +package gui + +import ( + "image" + "image/color" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/ebitenutil" + "github.com/hajimehoshi/ebiten/v2/text" + "golang.org/x/image/font" +) + +type Image struct { + img *ebiten.Image + bounds image.Rectangle +} + +func newImage(img *ebiten.Image) *Image { + return &Image{ + img: img, + bounds: img.Bounds(), + } +} + +func NewImage(width, height int) *Image { + return &Image{ + img: ebiten.NewImage(width, height), + bounds: image.Rect(0, 0, width, height), + } +} + +func (i *Image) SubImage(rect image.Rectangle) image.Image { + img := i.img.SubImage(rect).(*ebiten.Image) + return &Image{ + img: img, + bounds: img.Bounds().Intersect(i.bounds), + } +} + +func (i *Image) At(x, y int) color.Color { + return i.image().At(x, y) +} + +func (i *Image) Constrain(rect image.Rectangle) *Image { + return &Image{ + img: i.img, + bounds: i.bounds.Intersect(rect), + } +} + +func (i *Image) ColorModel() color.Model { + return i.image().ColorModel() +} + +func (i *Image) Const() image.Rectangle { + return i.bounds +} + +func (i *Image) Bounds() image.Rectangle { + return i.img.Bounds() +} + +func (i *Image) image() *ebiten.Image { + return i.img.SubImage(i.bounds).(*ebiten.Image) +} + +func (i *Image) Fill(c color.Color) { + i.image().Fill(c) +} + +func (i *Image) DrawString(s string, f font.Face, x, y int, c color.Color) { + text.Draw(i.image(), s, f, i.img.Bounds().Min.X+x, i.img.Bounds().Min.Y+y, c) +} + +func (i *Image) DrawImage(img *Image, op *ebiten.DrawImageOptions) { + i.image().DrawImage(img.img, op) +} + +func (i *Image) DrawRect(rect image.Rectangle, colr color.Color) { + ebitenutil.DrawRect(i.image(), float64(rect.Min.X), float64(rect.Min.Y), float64(rect.Dx()), float64(rect.Dy()), colr) +} diff --git a/layouts/layout_border.go b/layouts/layout_border.go new file mode 100644 index 0000000..42879af --- /dev/null +++ b/layouts/layout_border.go @@ -0,0 +1,183 @@ +package layouts + +import ( + "image" + + "git.milar.in/milarin/gui" +) + +// BorderLayout ia a gui.Layout which places its children onto a given gui.Side +type BorderLayout struct { + gui.ViewTmpl + views map[Slot]gui.View + horizontalLayout *LayoutResult + verticalLayout *LayoutResult + + viewDims map[Slot]gui.Dimension +} + +var _ gui.Layout = &BorderLayout{} + +func NewBorderLayout() *BorderLayout { + return &BorderLayout{ + views: map[Slot]gui.View{}, + viewDims: map[Slot]gui.Dimension{}, + } +} + +func (g *BorderLayout) Views() []gui.View { + s := make([]gui.View, 0, len(g.views)) + for _, view := range g.views { + s = append(s, view) + } + return s +} + +func (g *BorderLayout) SetView(v gui.View, slot Slot) { + g.views[slot] = v +} + +func (g *BorderLayout) View(slot Slot) gui.View { + return g.views[slot] +} + +func (g *BorderLayout) Draw(img *gui.Image, ctx gui.AppContext) { + g.ViewTmpl.Draw(img, ctx) + + if g.verticalLayout == nil { + g.Layout(ctx) + } + verticalLayout := g.verticalLayout + + if g.horizontalLayout == nil { + g.Layout(ctx) + } + horizontalLayout := g.horizontalLayout + + remainingVerticalSpacePerView := (img.Bounds().Dy() - verticalLayout.Sum.Height) + if verticalLayout.VerticalNegativeCount > 0 { + remainingVerticalSpacePerView /= verticalLayout.VerticalNegativeCount + } + + remainingHorizontalSpacePerView := (img.Bounds().Dx() - horizontalLayout.Sum.Width) + if horizontalLayout.HorizontalNegativeCount > 0 { + remainingHorizontalSpacePerView /= horizontalLayout.HorizontalNegativeCount + } + + fitsVertically := img.Bounds().Dy() >= verticalLayout.Sum.Height + fitsHorizontally := img.Bounds().Dx() >= horizontalLayout.Sum.Width + + var topHeight int + var bottomHeight int + var leftWidth int + var rightWidth int + + if view, ok := g.views[Top]; ok { + _, topHeight = view.Layout(ctx) + + if fitsVertically { + topHeight = iff(topHeight < 0, remainingVerticalSpacePerView, topHeight) + } else { + topHeight = int(float64(img.Bounds().Dy()) * float64(topHeight) / float64(verticalLayout.Sum.Height)) + } + + g.viewDims[Top] = gui.D(0, 0, img.Bounds().Dx(), topHeight) + view.Draw(img.SubImage(image.Rect(0, 0, img.Bounds().Dx(), topHeight)).(*gui.Image), ctx) + } + + if view, ok := g.views[Bottom]; ok { + _, bottomHeight = view.Layout(ctx) + + if fitsVertically { + bottomHeight = iff(bottomHeight < 0, remainingVerticalSpacePerView, bottomHeight) + } else { + bottomHeight = int(float64(img.Bounds().Dy()) * float64(bottomHeight) / float64(verticalLayout.Sum.Height)) + } + + g.viewDims[Bottom] = gui.D(0, img.Bounds().Dy()-bottomHeight, img.Bounds().Dx(), bottomHeight) + view.Draw(img.SubImage(image.Rect(0, img.Bounds().Dy()-bottomHeight, img.Bounds().Dx(), img.Bounds().Dy())).(*gui.Image), ctx) + } + + if view, ok := g.views[Left]; ok { + leftWidth, _ = view.Layout(ctx) + + if fitsHorizontally { + leftWidth = iff(leftWidth < 0, remainingHorizontalSpacePerView, leftWidth) + } else { + leftWidth = int(float64(img.Bounds().Dx()) * float64(leftWidth) / float64(horizontalLayout.Sum.Width)) + } + + g.viewDims[Left] = gui.D(0, topHeight, leftWidth, img.Bounds().Dy()-topHeight-bottomHeight) + view.Draw(img.SubImage(image.Rect(0, topHeight, leftWidth, img.Bounds().Dy()-bottomHeight)).(*gui.Image), ctx) + } + + if view, ok := g.views[Right]; ok { + rightWidth, _ = view.Layout(ctx) + + if fitsHorizontally { + rightWidth = iff(rightWidth < 0, remainingHorizontalSpacePerView, rightWidth) + } else { + rightWidth = int(float64(img.Bounds().Dx()) * float64(rightWidth) / float64(horizontalLayout.Sum.Width)) + } + + g.viewDims[Right] = gui.D(img.Bounds().Dx()-rightWidth, topHeight, rightWidth, img.Bounds().Dy()-topHeight-bottomHeight) + view.Draw(img.SubImage(image.Rect(img.Bounds().Dx()-rightWidth, topHeight, img.Bounds().Dx(), img.Bounds().Dy()-bottomHeight)).(*gui.Image), ctx) + } + + if view, ok := g.views[Center]; ok { + g.viewDims[Center] = gui.D(leftWidth, topHeight, img.Bounds().Dx()-leftWidth-rightWidth, img.Bounds().Dy()-topHeight-bottomHeight) + view.Draw(img.SubImage(image.Rect(leftWidth, topHeight, img.Bounds().Dx()-rightWidth, img.Bounds().Dy()-bottomHeight)).(*gui.Image), ctx) + } + + g.verticalLayout = nil + g.horizontalLayout = nil +} + +func (g *BorderLayout) Layout(ctx gui.AppContext) (prefWidth, prefHeight int) { + g.verticalLayout = CalculateLayoutResult(ctx, []gui.View{g.View(Top), g.View(Center), g.View(Bottom)}) + g.horizontalLayout = CalculateLayoutResult(ctx, []gui.View{g.View(Left), g.View(Center), g.View(Right)}) + return -1, -1 +} + +func (g *BorderLayout) OnMouseClick(event gui.MouseEvent) (consumed bool) { + for slot, dim := range g.viewDims { + if event.Position.In(dim) { + if g.views[slot].OnMouseClick(event.SubtractPos(dim.Point)) { + return true + } + } + } + return false +} + +func (g *BorderLayout) OnMouseMove(event gui.MouseEvent) (consumed bool) { + for slot, dim := range g.viewDims { + if event.Position.In(dim) { + if g.views[slot].OnMouseMove(event.SubtractPos(dim.Point)) { + return true + } + } + } + return false +} + +func (g *BorderLayout) OnMouseScroll(event gui.MouseEvent) (consumed bool) { + for slot, dim := range g.viewDims { + if event.Position.In(dim) { + if g.views[slot].OnMouseScroll(event.SubtractPos(dim.Point)) { + return true + } + } + } + return false +} + +type Slot string + +const ( + Top Slot = "top" + Bottom Slot = "bottom" + Left Slot = "left" + Right Slot = "right" + Center Slot = "center" +) diff --git a/layouts/layout_coord.go b/layouts/layout_coord.go new file mode 100644 index 0000000..13d98a6 --- /dev/null +++ b/layouts/layout_coord.go @@ -0,0 +1,58 @@ +package layouts + +import ( + "image" + + "git.milar.in/milarin/gui" +) + +// CoordLayout is a gui.Layout which places its children on predefined coordinates +type CoordLayout struct { + gui.ViewTmpl + views map[gui.View]gui.Dimension +} + +var _ gui.Layout = &CoordLayout{} + +func NewCoordLayout() *CoordLayout { + return &CoordLayout{ + views: map[gui.View]gui.Dimension{}, + } +} + +func (g *CoordLayout) Views() []gui.View { + s := make([]gui.View, 0, len(g.views)) + for v := range g.views { + s = append(s, v) + } + return s +} + +// SetView places v at the given coordinates with the given dimensions. +// v will be added to g's children if not already added before +func (g *CoordLayout) SetView(v gui.View, x, y, width, height int) { + g.views[v] = gui.Dimension{Point: gui.Point{X: x, Y: y}, Size: gui.Size{Width: width, Height: height}} +} + +func (g *CoordLayout) Draw(img *gui.Image, ctx gui.AppContext) { + for v, d := range g.views { + v.Draw(img.SubImage(image.Rect(d.X, d.Y, d.Width, d.Height)).(*gui.Image), ctx) + } +} + +func (v *CoordLayout) Layout(ctx gui.AppContext) (prefWidth, prefHeight int) { + return -1, -1 +} + +/* +func (g *CoordLayout) OnKeyPressed(event *gui.KeyEvent) (consumed bool) { + for _, view := range g.Views() { + if view.OnKeyPressed(event) { + return true + } + } + return false +} +*/ + +// TODO OnMouseEvent diff --git a/layouts/layout_flow.go b/layouts/layout_flow.go new file mode 100644 index 0000000..5a51d20 --- /dev/null +++ b/layouts/layout_flow.go @@ -0,0 +1,176 @@ +package layouts + +import ( + "image" + + "git.milar.in/milarin/gui" +) + +// FlowLayout ia a gui.Layout which places its children in a linear layout +type FlowLayout struct { + gui.ViewTmpl + views []gui.View + lastLayoutPhase *LayoutResult + + viewDims map[gui.View]gui.Dimension + + // Orientation defines in which direction the children will be placed + Orientation gui.Orientation +} + +var _ gui.Layout = &FlowLayout{} + +func NewFlowLayout(orientation gui.Orientation) *FlowLayout { + return &FlowLayout{ + views: make([]gui.View, 0), + viewDims: map[gui.View]gui.Dimension{}, + Orientation: orientation, + } +} + +func (g *FlowLayout) Views() []gui.View { + return g.views +} + +func (g *FlowLayout) AppendViews(v ...gui.View) { + g.views = append(g.views, v...) +} + +func (g *FlowLayout) PrependViews(v ...gui.View) { + g.views = append(v, g.views...) +} + +func (g *FlowLayout) InsertView(v gui.View, index int) { + g.views = append(g.views[:index], append([]gui.View{v}, g.views[index:]...)...) +} + +func (g *FlowLayout) removeView(v gui.View) { + for index, view := range g.Views() { + if v == view { + delete(g.viewDims, view) + g.views = append(g.views[:index], g.views[index+1:]...) + return + } + } +} + +func (g *FlowLayout) RemoveViews(v ...gui.View) { + views := append(make([]gui.View, 0, len(v)), v...) + for _, view := range views { + g.removeView(view) + } +} + +func (g *FlowLayout) Draw(img *gui.Image, ctx gui.AppContext) { + g.ViewTmpl.Draw(img, ctx) + + if g.lastLayoutPhase == nil { + g.Layout(ctx) + } + layout := g.lastLayoutPhase + + if g.Orientation == gui.Horizontal { + remainingSpacePerView := img.Bounds().Dx() - layout.Sum.Width + if remainingSpacePerView < 0 { + remainingSpacePerView = 0 + } + if layout.HorizontalNegativeCount > 0 { + remainingSpacePerView /= layout.HorizontalNegativeCount + } + + x := 0 + for _, view := range g.views { + size := layout.Sizes[view] + + size.Height = iff(size.Height < 0, img.Bounds().Dy(), size.Height) + if size.Width < 0 { + size.Width = iff(layout.Sum.Width > img.Bounds().Dx(), 0, remainingSpacePerView) + } + + g.viewDims[view] = gui.D(x, 0, size.Width, size.Height) + view.Draw(img.SubImage(image.Rect(x, 0, x+size.Width, size.Height)).(*gui.Image), ctx) + + x += size.Width + if x >= img.Bounds().Dx() { + break + } + } + } else if g.Orientation == gui.Vertical { + remainingSpacePerView := img.Bounds().Dy() - layout.Sum.Height + if remainingSpacePerView < 0 { + remainingSpacePerView = 0 + } + if layout.VerticalNegativeCount > 0 { + remainingSpacePerView /= layout.VerticalNegativeCount + } + + y := 0 + for _, view := range g.views { + size := layout.Sizes[view] + + size.Width = iff(size.Width < 0, img.Bounds().Dx(), size.Width) + if size.Height < 0 { + size.Height = iff(layout.Sum.Height > img.Bounds().Dy(), 0, remainingSpacePerView) + } + + g.viewDims[view] = gui.D(0, y, size.Width, size.Height) + view.Draw(img.SubImage(image.Rect(0, y, size.Width, y+size.Height)).(*gui.Image), ctx) + + y += size.Height + if y >= img.Bounds().Dy() { + break + } + } + } + + g.lastLayoutPhase = nil +} + +func (g *FlowLayout) Layout(ctx gui.AppContext) (prefWidth, prefHeight int) { + layout := CalculateLayoutResult(ctx, g.Views()) + g.lastLayoutPhase = layout + + if g.Orientation == gui.Horizontal { + prefWidth = iff(layout.HorizontalNegativeCount == 0, layout.Sum.Width, -1) + prefHeight = iff(layout.VerticalNegativeCount == 0, layout.Max.Height, -1) + } else if g.Orientation == gui.Vertical { + prefWidth = iff(layout.HorizontalNegativeCount == 0, layout.Max.Width, -1) + prefHeight = iff(layout.VerticalNegativeCount == 0, layout.Sum.Height, -1) + } + + layout.Pref = gui.Size{Width: prefWidth, Height: prefHeight} + return +} + +func (g *FlowLayout) OnMouseClick(event gui.MouseEvent) (consumed bool) { + for view, dim := range g.viewDims { + if event.Position.In(dim) { + if view.OnMouseClick(event.SubtractPos(dim.Point)) { + return true + } + } + } + return false +} + +func (g *FlowLayout) OnMouseMove(event gui.MouseEvent) (consumed bool) { + for view, dim := range g.viewDims { + if event.Position.In(dim) { + if view.OnMouseMove(event.SubtractPos(dim.Point)) { + return true + } + } + } + return false +} + +func (g *FlowLayout) OnMouseScroll(event gui.MouseEvent) (consumed bool) { + for view, dim := range g.viewDims { + if event.Position.In(dim) { + if view.OnMouseScroll(event.SubtractPos(dim.Point)) { + return true + } + } + } + return false +} diff --git a/layouts/utils.go b/layouts/utils.go new file mode 100644 index 0000000..b34a2c4 --- /dev/null +++ b/layouts/utils.go @@ -0,0 +1,95 @@ +package layouts + +import ( + "image" + "math" + + "git.milar.in/milarin/gui" +) + +func min(x, y int) int { + if x < y { + return x + } + return y +} + +func max(x, y int) int { + if x > y { + return x + } + return y +} + +func limit(v, minv, maxv int) int { + return min(max(v, minv), maxv) +} + +func iff[T any](condition bool, trueValue, falseValue T) T { + if condition { + return trueValue + } + return falseValue +} + +type LayoutResult struct { + Sizes map[gui.View]gui.Size + + Sum gui.Size + Min gui.Size + Max gui.Size + Pref gui.Size + + Count int + + VerticalNegativeCount int + HorizontalNegativeCount int +} + +func CalculateLayoutResult(ctx gui.AppContext, views []gui.View) *LayoutResult { + result := &LayoutResult{ + Sizes: map[gui.View]gui.Size{}, + + Sum: gui.Size{Width: 0, Height: 0}, + Min: gui.Size{Width: math.MaxInt, Height: math.MaxInt}, + Max: gui.Size{Width: -1, Height: -1}, + + Count: 0, + + VerticalNegativeCount: 0, + HorizontalNegativeCount: 0, + } + + for _, view := range views { + if view == nil { + continue + } + + result.Count++ + + width, height := view.Layout(ctx) + result.Sizes[view] = gui.Size{Width: width, Height: height} + + if width > 0 { + result.Sum.Width += width + result.Min.Width = min(result.Min.Width, width) + result.Max.Width = max(result.Max.Width, width) + } else if width < 0 { + result.HorizontalNegativeCount++ + } + + if height > 0 { + result.Sum.Height += height + result.Min.Height = min(result.Min.Height, height) + result.Max.Height = max(result.Max.Height, height) + } else if height < 0 { + result.VerticalNegativeCount++ + } + } + + return result +} + +func rect(x, y, w, h int) image.Rectangle { + return image.Rect(x, y, x+w, y+h) +} diff --git a/style.go b/style.go new file mode 100644 index 0000000..74a42f0 --- /dev/null +++ b/style.go @@ -0,0 +1,79 @@ +package gui + +import ( + "image/color" + + "golang.org/x/image/font" +) + +type Style interface { + SetForeground(c color.Color) + Foreground(ctx AppContext) color.Color + + SetBackground(c color.Color) + Background(ctx AppContext) color.Color + + SetFont(f *Font) + Font(ctx AppContext) *Font + + SetFontSize(fontSize int) + FontSize(ctx AppContext) int + + FontFace(ctx AppContext) font.Face +} + +type StyleTmpl struct { + foreground color.Color + background color.Color + + font *Font + fontSize *int +} + +func (s *StyleTmpl) SetForeground(c color.Color) { + s.foreground = c +} + +func (s *StyleTmpl) Foreground(ctx AppContext) color.Color { + if s.foreground == nil { + return ctx.Foreground() + } + return s.foreground +} + +func (s *StyleTmpl) SetBackground(c color.Color) { + s.background = c +} + +func (s *StyleTmpl) Background(ctx AppContext) color.Color { + if s.background == nil { + return ctx.Background() + } + return s.background +} + +func (s *StyleTmpl) SetFont(f *Font) { + s.font = f +} + +func (s *StyleTmpl) Font(ctx AppContext) *Font { + if s.font == nil { + return ctx.Font() + } + return s.font +} + +func (s *StyleTmpl) SetFontSize(fontSize int) { + s.fontSize = &fontSize +} + +func (s *StyleTmpl) FontSize(ctx AppContext) int { + if s.fontSize == nil { + return ctx.FontSize() + } + return *s.fontSize +} + +func (s *StyleTmpl) FontFace(ctx AppContext) font.Face { + return s.Font(ctx).Face(s.FontSize(ctx)) +} diff --git a/tests/main.go b/tests/main.go new file mode 100644 index 0000000..55ae0e5 --- /dev/null +++ b/tests/main.go @@ -0,0 +1,153 @@ +package main + +import ( + "image/color" + "math" + "math/rand" + "strconv" + "time" + + "git.milar.in/milarin/gui" + "git.milar.in/milarin/gui/layouts" + "git.milar.in/milarin/gui/views" +) + +func main() { + //AnchorViewTest() + //FlowLayoutTest() + ScrollLayoutTest() + //BorderLayoutTest() + //TextViewTest() +} + +func AnchorViewTest() { + textView := views.NewTextView("hello world") + textView.SetBackground(color.NRGBA{255, 0, 0, 255}) + anchorView := views.NewAnchorView(textView, gui.AnchorBottomRight) + + a, err := gui.NewApp(anchorView) + if err != nil { + panic(err) + } + + a.Background = color.Transparent + + if err := a.Start(); err != nil { + panic(err) + } +} + +func ScrollLayoutTest() { + textViews := make([]gui.View, 0, 100) + rnd := rand.New(rand.NewSource(time.Now().Unix())) + for i := 0; i < cap(textViews); i++ { + textView := views.NewTextView(strconv.Itoa(i + 1)) + marginView := views.NewMarginView(textView, 10, 10, 10, 10) + growView := views.NewGrowView(marginView, true, false) + constrainView := views.NewConstrainView(growView, 500, -1) + + textViews = append(textViews, constrainView) + textViews[i].SetBackground(color.NRGBA{ + uint8(rnd.Intn(math.MaxUint8)), + uint8(rnd.Intn(math.MaxUint8)), + uint8(rnd.Intn(math.MaxUint8)), + 255, + }) + /*textView.MouseEvent = func(event gui.MouseEvent) (consumed bool) { + textView.SetBackground(color.White) + return true + }*/ + } + + flowLayout := layouts.NewFlowLayout(gui.Vertical) + flowLayout.AppendViews(textViews...) + flowLayout.SetBackground(color.White) + + scrollView := views.NewScrollView(flowLayout) + //scrollView.Scroll(0, 100) + + a, err := gui.NewApp(scrollView) + if err != nil { + panic(err) + } + + a.Background = color.Transparent + + if err := a.Start(); err != nil { + panic(err) + } +} + +func FlowLayoutTest() { + textViews := make([]gui.View, 0, 10) + rnd := rand.New(rand.NewSource(time.Now().Unix())) + for i := 0; i < cap(textViews); i++ { + textView := views.NewTextView(strconv.Itoa(i + 1)) + textViews = append(textViews, textView) + textViews[i].SetBackground(color.NRGBA{ + uint8(rnd.Intn(math.MaxUint8)), + uint8(rnd.Intn(math.MaxUint8)), + uint8(rnd.Intn(math.MaxUint8)), + 255, + }) + textView.MouseMove = func(event gui.MouseEvent) (consumed bool) { + textView.SetBackground(color.White) + return true + } + } + + flowLayout := layouts.NewFlowLayout(gui.Vertical) + flowLayout.AppendViews(textViews...) + + a, err := gui.NewApp(flowLayout) + if err != nil { + panic(err) + } + + if err := a.Start(); err != nil { + panic(err) + } +} + +func TextViewTest() { + a, err := gui.NewApp(views.NewTextView("hello world")) + if err != nil { + panic(err) + } + + if err := a.Start(); err != nil { + panic(err) + } +} + +func BorderLayoutTest() { + top := views.NewTextView("top") + bottom := views.NewTextView("bottom") + left := views.NewTextView("left") + right := views.NewTextView("right") + center := views.NewTextView("center") + + top.SetBackground(color.NRGBA{255, 200, 200, 255}) + bottom.SetBackground(color.NRGBA{200, 255, 200, 255}) + left.SetBackground(color.NRGBA{200, 200, 255, 255}) + right.SetBackground(color.NRGBA{255, 255, 200, 255}) + center.SetBackground(color.NRGBA{200, 255, 255, 255}) + + top.SetFontSize(48) + + borderLayout := layouts.NewBorderLayout() + borderLayout.SetView(top, layouts.Top) + borderLayout.SetView(bottom, layouts.Bottom) + borderLayout.SetView(left, layouts.Left) + borderLayout.SetView(right, layouts.Right) + borderLayout.SetView(center, layouts.Center) + + a, err := gui.NewApp(borderLayout) + if err != nil { + panic(err) + } + + if err := a.Start(); err != nil { + panic(err) + } +} diff --git a/tests/tests b/tests/tests new file mode 100755 index 0000000..227aa54 Binary files /dev/null and b/tests/tests differ diff --git a/tmpl_view.go b/tmpl_view.go new file mode 100644 index 0000000..86e276c --- /dev/null +++ b/tmpl_view.go @@ -0,0 +1,19 @@ +package gui + +type ViewTmpl struct { + EventTmpl + StyleTmpl +} + +var _ View = &ViewTmpl{} + +func (v *ViewTmpl) Draw(img *Image, ctx AppContext) { + bg := v.Background(ctx) + if _, _, _, a := bg.RGBA(); a > 0 { + img.DrawRect(img.Bounds(), v.Background(ctx)) + } +} + +func (v *ViewTmpl) Layout(ctx AppContext) (prefWidth, prefHeight int) { + return -1, -1 +} diff --git a/tmpl_wrapper.go b/tmpl_wrapper.go new file mode 100644 index 0000000..45b43a9 --- /dev/null +++ b/tmpl_wrapper.go @@ -0,0 +1,55 @@ +package gui + +type WrapperTmpl struct { + ViewTmpl + view View +} + +var _ Wrapper = &WrapperTmpl{} + +func (v *WrapperTmpl) Layout(ctx AppContext) (prefWidth, prefHeight int) { + if v.view != nil { + return v.view.Layout(ctx) + } + return v.ViewTmpl.Layout(ctx) +} + +func (v *WrapperTmpl) Draw(img *Image, ctx AppContext) { + v.ViewTmpl.Draw(img, ctx) + if v.view != nil { + v.view.Draw(img, ctx) + } +} + +func (v *WrapperTmpl) OnMouseClick(event MouseEvent) (consumed bool) { + if v.view != nil { + return v.view.OnMouseClick(event) + } + return v.ViewTmpl.OnMouseClick(event) +} + +func (v *WrapperTmpl) OnMouseMove(event MouseEvent) (consumed bool) { + if v.view != nil { + return v.view.OnMouseMove(event) + } + return v.ViewTmpl.OnMouseMove(event) +} + +func (v *WrapperTmpl) OnMouseScroll(event MouseEvent) (consumed bool) { + if v.view != nil { + return v.view.OnMouseScroll(event) + } + return v.ViewTmpl.OnMouseScroll(event) +} + +func (v *WrapperTmpl) Views() []View { + return []View{v.view} +} + +func (v *WrapperTmpl) View() View { + return v.view +} + +func (v *WrapperTmpl) SetView(view View) { + v.view = view +} diff --git a/types.go b/types.go new file mode 100644 index 0000000..9d9c86e --- /dev/null +++ b/types.go @@ -0,0 +1,194 @@ +package gui + +import ( + "fmt" + "image" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/inpututil" +) + +type Point struct { + X, Y int +} + +func P(x, y int) Point { + return Point{x, y} +} + +func (p Point) Add(o Point) Point { + return Point{p.X + o.X, p.Y + o.Y} +} + +func (p Point) Sub(o Point) Point { + return Point{p.X - o.X, p.Y - o.Y} +} + +func (p Point) Mul(o Point) Point { + return Point{p.X * o.X, p.Y * o.Y} +} + +func (p Point) Div(o Point) Point { + return Point{p.X / o.X, p.Y / o.Y} +} + +func (p Point) Pt() image.Point { + return image.Pt(p.X, p.Y) +} + +func Pf(x, y float64) Point { + return Point{int(x), int(y)} +} + +func (p Point) In(d Dimension) bool { + return p.X >= d.X && p.X < d.X+d.Width && p.Y >= d.Y && p.Y < d.Y+d.Height +} + +func (p Point) String() string { + return fmt.Sprintf("P(%d, %d)", p.X, p.Y) +} + +type Size struct { + Width, Height int +} + +func S(w, h int) Size { + return Size{w, h} +} + +func (s Size) Pt() image.Point { + return image.Pt(s.Width, s.Height) +} + +func (s Size) String() string { + return fmt.Sprintf("S(%d, %d)", s.Width, s.Height) +} + +type Dimension struct { + Point + Size +} + +func D(x, y, w, h int) Dimension { + return Dimension{P(x, y), S(w, h)} +} + +func (d Dimension) Rect() image.Rectangle { + return image.Rectangle{Min: d.Point.Pt(), Max: d.Point.Pt().Add(d.Size.Pt())} +} + +func (d Dimension) String() string { + return fmt.Sprintf("D(%d, %d, %d, %d)", d.X, d.Y, d.Width, d.Height) +} + +type Orientation uint8 + +const ( + Horizontal Orientation = iota + Vertical +) + +type Side uint8 + +const ( + SideTop Side = iota + SideBottom + SideLeft + SideRight +) + +// Horizontal returns true if s is either SideLeft or SideRight +func (s Side) Horizontal() bool { + return s == SideLeft || s == SideRight +} + +// Vertical returns true if s is either SideTop or SideBottom +func (s Side) Vertical() bool { + return s == SideTop || s == SideBottom +} + +type Anchor uint8 + +const ( + AnchorTopLeft Anchor = iota + AnchorTop + AnchorTopRight + AnchorLeft + AnchorCenter + AnchorRight + AnchorBottomLeft + AnchorBottom + AnchorBottomRight +) + +type MouseButton uint8 + +const ( + MouseButtonLeft = MouseButton(ebiten.MouseButtonLeft) + MouseButtonRight = MouseButton(ebiten.MouseButtonRight) + MouseButtonMiddle = MouseButton(ebiten.MouseButtonMiddle) +) + +type ButtonMask uint16 + +func Buttons() ButtonMask { + var mask ButtonMask + + if ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) { + mask |= 1 << MouseButtonLeft + } + + if ebiten.IsMouseButtonPressed(ebiten.MouseButtonRight) { + mask |= 1 << MouseButtonRight + } + + if ebiten.IsMouseButtonPressed(ebiten.MouseButtonMiddle) { + mask |= 1 << MouseButtonMiddle + } + + if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) { + mask |= 1 << (MouseButtonLeft + 3) + } + + if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonRight) { + mask |= 1 << (MouseButtonRight + 3) + } + + if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonMiddle) { + mask |= 1 << (MouseButtonMiddle + 3) + } + + if inpututil.IsMouseButtonJustReleased(ebiten.MouseButtonLeft) { + mask |= 1 << (MouseButtonLeft + 6) + } + + if inpututil.IsMouseButtonJustReleased(ebiten.MouseButtonRight) { + mask |= 1 << (MouseButtonRight + 6) + } + + if inpututil.IsMouseButtonJustReleased(ebiten.MouseButtonMiddle) { + mask |= 1 << (MouseButtonMiddle + 6) + } + + return mask +} + +func (m ButtonMask) Pressed(button MouseButton) bool { + return m&(1< 0 +} + +func (m ButtonMask) JustPressed(button MouseButton) bool { + return m&(1< 0 +} + +func (m ButtonMask) Clicked(button MouseButton) bool { + return m&(1< 0 +} + +func (m ButtonMask) anyPressed() bool { + return m.Pressed(MouseButtonLeft) || m.Pressed(MouseButtonMiddle) || m.Pressed(MouseButtonRight) +} + +func (m ButtonMask) anyClicked() bool { + return m.Clicked(MouseButtonLeft) || m.Clicked(MouseButtonMiddle) || m.Clicked(MouseButtonRight) +} diff --git a/view.go b/view.go new file mode 100644 index 0000000..991088b --- /dev/null +++ b/view.go @@ -0,0 +1,31 @@ +package gui + +// View defines the behavior of any element displayable on screen. +// To define custom Views, it is recommended to add ViewTmpl +// as the promoted anonymous field for your custom View struct. +// It implements the View interface with useful default behavior +type View interface { + Events + Style + + Layout(ctx AppContext) (prefWidth, prefHeight int) + Draw(img *Image, ctx AppContext) +} + +// Layout defines the behavior of a View which can hold multiple sub views +type Layout interface { + View + + Views() []View +} + +// Wrapper defines the behavior of a Layout which can hold exactly one sub view. +// To define custom Wrappers, it is recommended to add WrapperTmpl +// as the promoted anonymous field for your custom Wrapper struct. +// It implements the Wrapper interface with useful default behavior +type Wrapper interface { + Layout + + SetView(View) + View() View +} diff --git a/views/utils.go b/views/utils.go new file mode 100644 index 0000000..6c0cffb --- /dev/null +++ b/views/utils.go @@ -0,0 +1,26 @@ +package views + +func min[T int | float64](x, y T) T { + if x < y { + return x + } + return y +} + +func max[T int | float64](x, y T) T { + if x > y { + return x + } + return y +} + +func limit[T int | float64](v, minv, maxv T) T { + return min(max(v, minv), maxv) +} + +func iff[T any](condition bool, trueValue, falseValue T) T { + if condition { + return trueValue + } + return falseValue +} diff --git a/views/view_anchor.go b/views/view_anchor.go new file mode 100644 index 0000000..ef8243b --- /dev/null +++ b/views/view_anchor.go @@ -0,0 +1,57 @@ +package views + +import ( + "image" + + "git.milar.in/milarin/gui" +) + +type AnchorView struct { + gui.WrapperTmpl + Anchor gui.Anchor +} + +var _ gui.View = &AnchorView{} + +func NewAnchorView(view gui.View, anchor gui.Anchor) *AnchorView { + v := &AnchorView{Anchor: anchor} + v.SetView(view) + return v +} + +func (v *AnchorView) Draw(img *gui.Image, ctx gui.AppContext) { + var pos image.Point + + w, h := v.View().Layout(ctx) + w = iff(w >= 0, w, img.Bounds().Dx()) + h = iff(h >= 0, h, img.Bounds().Dy()) + + vw, vh := img.Bounds().Dx(), img.Bounds().Dy() + + switch v.Anchor { + case gui.AnchorTopLeft: + pos = image.Pt(0, 0) + case gui.AnchorTop: + pos = image.Pt(vw/2-w/2, 0) + case gui.AnchorTopRight: + pos = image.Pt(vw-w, 0) + case gui.AnchorLeft: + pos = image.Pt(0, vh/2-h/2) + case gui.AnchorCenter: + pos = image.Pt(vw/2-w/2, vh/2-h/2) + case gui.AnchorRight: + pos = image.Pt(vw-w, vh/2-h/2) + case gui.AnchorBottomLeft: + pos = image.Pt(0, vh-h) + case gui.AnchorBottom: + pos = image.Pt(0, vh-h) + case gui.AnchorBottomRight: + pos = image.Pt(vw-w, vh-h) + default: + panic("invalid anchor in AnchorView") + } + + pos = pos.Add(img.Bounds().Min) + rect := image.Rect(pos.X, pos.Y, pos.X+w, pos.Y+h) + v.View().Draw(img.SubImage(rect).(*gui.Image), ctx) +} diff --git a/views/view_constrain.go b/views/view_constrain.go new file mode 100644 index 0000000..e23a6d9 --- /dev/null +++ b/views/view_constrain.go @@ -0,0 +1,47 @@ +package views + +import ( + "git.milar.in/milarin/gui" +) + +// ConstrainView is a gui.Wrapper which constrains the dimensions of its View +type ConstrainView struct { + gui.WrapperTmpl + MaxWidth int + MaxHeight int +} + +var _ gui.Wrapper = &ConstrainView{} + +func NewConstrainView(view gui.View, maxWidth, maxHeight int) *ConstrainView { + v := new(ConstrainView) + v.SetView(view) + v.Constrain(maxWidth, maxHeight) + return v +} + +func (v *ConstrainView) Constrain(maxWidth, maxHeight int) { + v.MaxWidth, v.MaxHeight = maxWidth, maxHeight +} + +func (v *ConstrainView) Layout(ctx gui.AppContext) (prefWidth, prefHeight int) { + if v.View() == nil { + return v.MaxWidth, v.MaxHeight + } + + vw, vh := v.View().Layout(ctx) + + if v.MaxWidth >= 0 { + prefWidth = iff(vw >= 0, min(vw, v.MaxWidth), v.MaxWidth) + } else { + prefWidth = vw + } + + if v.MaxHeight >= 0 { + prefHeight = iff(vh >= 0, min(vh, v.MaxHeight), v.MaxHeight) + } else { + prefHeight = vh + } + + return +} diff --git a/views/view_grow.go b/views/view_grow.go new file mode 100644 index 0000000..f437e7f --- /dev/null +++ b/views/view_grow.go @@ -0,0 +1,25 @@ +package views + +import ( + "git.milar.in/milarin/gui" +) + +// GrowView is a gui.Wrapper which always demands all available space in the given axes +type GrowView struct { + gui.WrapperTmpl + + growH, growV bool +} + +var _ gui.View = &GrowView{} + +func NewGrowView(view gui.View, growH, growV bool) *GrowView { + g := &GrowView{growH: growH, growV: growV} + g.SetView(view) + return g +} + +func (v *GrowView) Layout(ctx gui.AppContext) (prefWidth, prefHeight int) { + w, h := v.WrapperTmpl.Layout(ctx) + return iff(v.growH, -1, w), iff(v.growV, -1, h) +} diff --git a/views/view_margin.go b/views/view_margin.go new file mode 100644 index 0000000..374792d --- /dev/null +++ b/views/view_margin.go @@ -0,0 +1,48 @@ +package views + +import ( + "image" + + "git.milar.in/milarin/gui" +) + +// MarginView is a gui.Wrapper which applies margin around its view +type MarginView struct { + gui.WrapperTmpl + Margin map[gui.Side]int +} + +var _ gui.Wrapper = &MarginView{} + +func NewMarginView(view gui.View, top, right, bottom, left int) *MarginView { + v := new(MarginView) + v.SetView(view) + v.SetMargin(top, right, bottom, left) + return v +} + +func (v *MarginView) Layout(ctx gui.AppContext) (prefWidth, prefHeight int) { + w, h := v.View().Layout(ctx) + w = iff(w > 0, w+v.Margin[gui.SideLeft]+v.Margin[gui.SideRight], w) + h = iff(h > 0, h+v.Margin[gui.SideTop]+v.Margin[gui.SideBottom], h) + return w, h +} + +func (g *MarginView) Draw(img *gui.Image, ctx gui.AppContext) { + x := img.Bounds().Min.X + g.Margin[gui.SideLeft] + y := img.Bounds().Min.Y + g.Margin[gui.SideTop] + w := img.Bounds().Max.X - g.Margin[gui.SideRight] + h := img.Bounds().Max.Y - g.Margin[gui.SideBottom] + + g.ViewTmpl.Draw(img, ctx) + g.View().Draw(img.SubImage(image.Rect(x, y, w, h)).(*gui.Image), ctx) +} + +func (v *MarginView) SetMargin(top, right, bottom, left int) { + v.Margin = map[gui.Side]int{ + gui.SideTop: top, + gui.SideRight: right, + gui.SideBottom: bottom, + gui.SideLeft: left, + } +} diff --git a/views/view_scroll.go b/views/view_scroll.go new file mode 100644 index 0000000..9242777 --- /dev/null +++ b/views/view_scroll.go @@ -0,0 +1,190 @@ +package views + +import ( + "image" + "math" + "time" + + "git.milar.in/milarin/gui" + "github.com/hajimehoshi/ebiten/v2" +) + +// ScrollView is a gui.View which can hold an arbitrary large view. +// If the sub view does not fit into its bounds, scroll bars will be shown +type ScrollView struct { + gui.WrapperTmpl + + buf *gui.Image + + viewportWidth, viewportHeight int + viewWidth, viewHeight int + + VerticalScrollOffset float64 + HorizontalScrollOffset float64 + ScrollStep int + + targetOffset gui.Point + ScrollSpeed int + lastDraw time.Time + + verticalScrollBar *ScrollbarView + horizontalScrollBar *ScrollbarView +} + +var _ gui.Wrapper = &ScrollView{} + +func NewScrollView(view gui.View) *ScrollView { + v := new(ScrollView) + v.SetView(view) + v.ScrollStep = 100 + v.ScrollSpeed = 1000 + v.lastDraw = time.Now() + v.verticalScrollBar = NewScrollbarView(gui.Vertical) + v.horizontalScrollBar = NewScrollbarView(gui.Horizontal) + return v +} + +func (v *ScrollView) update(deltaTime float64) { + deltaSpeed := deltaTime * float64(v.ScrollSpeed) + + if math.Abs(v.VerticalScrollOffset-float64(v.targetOffset.Y)) >= deltaSpeed { + vDir := iff(v.VerticalScrollOffset-float64(v.targetOffset.Y) < 0, 1.0, -1.0) + v.VerticalScrollOffset += deltaSpeed * vDir + } else { + v.VerticalScrollOffset = float64(v.targetOffset.Y) + } + + if math.Abs(v.HorizontalScrollOffset-float64(v.targetOffset.X)) >= deltaSpeed { + hDir := iff(v.HorizontalScrollOffset-float64(v.targetOffset.X) < 0, 1.0, -1.0) + v.HorizontalScrollOffset += deltaSpeed * hDir + } else { + v.HorizontalScrollOffset = float64(v.targetOffset.X) + } +} + +func (v *ScrollView) Layout(ctx gui.AppContext) (prefWidth, prefHeight int) { + return -1, -1 +} + +func (v *ScrollView) Draw(img *gui.Image, ctx gui.AppContext) { + now := time.Now() + deltaTime := now.Sub(v.lastDraw).Seconds() + v.lastDraw = now + + v.viewWidth = img.Bounds().Dx() + v.viewHeight = img.Bounds().Dy() + + v.update(deltaTime) + + v.ViewTmpl.Draw(img, ctx) + + w, h := v.View().Layout(ctx) + w = iff(w >= 0, w, img.Bounds().Dx()-v.verticalScrollBar.Thickness) + h = iff(h >= 0, h, img.Bounds().Dy()-v.horizontalScrollBar.Thickness) + + if v.buf == nil || v.buf.Bounds().Dx() != w || v.buf.Bounds().Dy() != h { + v.buf = gui.NewImage(w, h) + } + + v.limit() + v.View().Draw(v.buf, ctx) + + scrollH, scrollV := v.determineViewportSize(img) + copyBufferWidth, copyBufferHeight := 0, 0 + + if scrollH { + copyBufferWidth = v.viewportWidth + } else { + copyBufferWidth = v.buf.Bounds().Dx() + } + + if scrollV { + copyBufferHeight = v.viewportHeight + } else { + copyBufferHeight = v.buf.Bounds().Dy() + } + + // copy buffer + min := image.Pt(int(v.HorizontalScrollOffset), int(v.VerticalScrollOffset)) + size := image.Pt(copyBufferWidth, copyBufferHeight) + max := min.Add(size) + rect := image.Rectangle{min, max} + op := new(ebiten.DrawImageOptions) + //op.GeoM.Translate(-float64(min.X), -float64(min.Y)) + img.DrawImage(v.buf.SubImage(rect).(*gui.Image), op) //-min.X, -min.Y) + + if scrollV { + v.verticalScrollBar.ViewSize = v.buf.Bounds().Dy() + v.verticalScrollBar.ViewportSize = size.Y + v.verticalScrollBar.ViewportPos = int(v.VerticalScrollOffset) + v.verticalScrollBar.Draw(img.SubImage(image.Rect(img.Bounds().Max.X-v.verticalScrollBar.Thickness, 0, img.Bounds().Max.X, img.Bounds().Max.Y)).(*gui.Image), ctx) + } + + if scrollH { + v.horizontalScrollBar.ViewSize = v.buf.Bounds().Dx() + v.horizontalScrollBar.ViewportSize = size.X + v.horizontalScrollBar.ViewportPos = int(v.HorizontalScrollOffset) + v.horizontalScrollBar.Draw(img.SubImage(image.Rect(0, img.Bounds().Max.Y-v.horizontalScrollBar.Thickness, img.Bounds().Max.X, img.Bounds().Max.Y)).(*gui.Image), ctx) + } +} + +func (v *ScrollView) limit() { + if v.buf != nil { + v.targetOffset.Y = limit(v.targetOffset.Y, 0, max(v.buf.Bounds().Dy()-v.viewportHeight, 0)) + v.targetOffset.X = limit(v.targetOffset.X, 0, max(v.buf.Bounds().Dx()-v.viewportWidth, 0)) + v.VerticalScrollOffset = limit(v.VerticalScrollOffset, 0, float64(max(v.buf.Bounds().Dy()-v.viewportHeight, 0))) + v.HorizontalScrollOffset = limit(v.HorizontalScrollOffset, 0, float64(max(v.buf.Bounds().Dx()-v.viewportWidth, 0))) + } +} + +func (v *ScrollView) Scroll(horizontalOffset, verticalOffset int) { + v.targetOffset.Y = v.targetOffset.Y + verticalOffset + v.targetOffset.X = v.targetOffset.X + horizontalOffset +} + +func (v *ScrollView) determineViewportSize(img *gui.Image) (scrollbarH, scrollbarV bool) { + v.viewportWidth, v.viewportHeight = img.Bounds().Dx()-v.verticalScrollBar.Thickness, img.Bounds().Dy()-v.horizontalScrollBar.Thickness + scrollbarV = v.buf.Bounds().Dy() > v.viewportHeight + scrollbarH = v.buf.Bounds().Dx() > v.viewportWidth + + if scrollbarV && !scrollbarH { + v.viewportHeight += v.horizontalScrollBar.Thickness + scrollbarV = v.buf.Bounds().Dy() > v.viewportHeight + } + + if !scrollbarV && scrollbarH { + v.viewportWidth += v.verticalScrollBar.Thickness + scrollbarH = v.buf.Bounds().Dx() > v.viewportWidth + } + + return +} + +func (v *ScrollView) OnMouseClick(event gui.MouseEvent) (consumed bool) { + return v.View().OnMouseClick(event.AddPos(gui.P(int(v.HorizontalScrollOffset), int(v.VerticalScrollOffset)))) +} + +func (v *ScrollView) OnMouseMove(event gui.MouseEvent) (consumed bool) { + if event.Position.In(gui.D(v.viewportWidth, 0, v.viewWidth-v.viewportWidth, v.viewHeight)) { + return v.verticalScrollBar.OnMouseMove(event.SubtractPos(gui.P(v.viewportWidth, 0))) + } + + if event.Position.In(gui.D(0, v.viewportHeight, v.viewWidth, v.viewHeight-v.viewportHeight)) { + return v.horizontalScrollBar.OnMouseMove(event.SubtractPos(gui.P(0, v.viewportHeight))) + } + + return v.View().OnMouseMove(event.AddPos(gui.P(int(v.HorizontalScrollOffset), int(v.VerticalScrollOffset)))) +} + +func (v *ScrollView) OnMouseScroll(event gui.MouseEvent) (consumed bool) { + if v.View().OnMouseScroll(event.AddPos(gui.P(int(v.HorizontalScrollOffset), int(v.VerticalScrollOffset)))) { + return true + } + + if event.Wheel.X != 0 || event.Wheel.Y != 0 { + v.Scroll(-event.Wheel.X*v.ScrollStep, -event.Wheel.Y*v.ScrollStep) + return true + } + + return false +} diff --git a/views/view_scrollbar.go b/views/view_scrollbar.go new file mode 100644 index 0000000..c1186ef --- /dev/null +++ b/views/view_scrollbar.go @@ -0,0 +1,73 @@ +package views + +import ( + "fmt" + "image" + + "git.milar.in/milarin/gui" +) + +type ScrollbarView struct { + gui.ViewTmpl + Orientation gui.Orientation + Thickness int + + ViewSize int + ViewportPos int + ViewportSize int + + width, height int +} + +var _ gui.View = &ScrollbarView{} + +func NewScrollbarView(orientation gui.Orientation) *ScrollbarView { + return &ScrollbarView{ + Orientation: orientation, + Thickness: 15, + } +} + +func (v *ScrollbarView) Layout(ctx gui.AppContext) (prefWidth, prefHeight int) { + if v.Orientation == gui.Horizontal { + return -1, v.Thickness + } + return v.Thickness, -1 +} + +func (v *ScrollbarView) Draw(img *gui.Image, ctx gui.AppContext) { + v.ViewTmpl.Draw(img, ctx) + v.width, v.height = img.Bounds().Dx(), img.Bounds().Dy() + + var x, y, w, h int + + if v.Orientation == gui.Vertical { + x = img.Bounds().Min.X + y = img.Bounds().Min.Y + v.ViewportPos*img.Bounds().Dy()/v.ViewSize + w = img.Bounds().Max.X + h = v.ViewportSize*img.Bounds().Dy()/v.ViewSize + 1 + } else if v.Orientation == gui.Horizontal { + x = img.Bounds().Min.X + v.ViewportPos*img.Bounds().Dx()/v.ViewSize + y = img.Bounds().Min.Y + w = v.ViewportSize*img.Bounds().Dx()/v.ViewSize + 1 + h = img.Bounds().Max.Y + } + + img.DrawRect(image.Rect(x, y, x+w, y+h), v.Foreground(ctx)) +} + +func (v *ScrollbarView) OnMouseMove(event gui.MouseEvent) (consumed bool) { + if v.Orientation == gui.Vertical { + y := v.ViewportPos * v.height / v.ViewSize + h := v.ViewportSize*v.height/v.ViewSize + 1 + + fmt.Println(event.Buttons) + // TODO because MouseMove is only fired when mouse is moved, JustPressed button events don't work + + if event.Position.Y > y && event.Position.Y < y+h && event.JustPressed(gui.MouseButtonLeft) { + fmt.Println("move start") + } + } + + return true +} diff --git a/views/view_text.go b/views/view_text.go new file mode 100644 index 0000000..1cf84d2 --- /dev/null +++ b/views/view_text.go @@ -0,0 +1,30 @@ +package views + +import ( + "git.milar.in/milarin/gui" +) + +// TextView is a gui.View which prints text +type TextView struct { + gui.ViewTmpl + Text string +} + +var _ gui.View = &TextView{} + +func NewTextView(text string) *TextView { + return &TextView{ + Text: text, + } +} + +func (v *TextView) Layout(ctx gui.AppContext) (prefWidth, prefHeight int) { + s := ctx.MeasureString(v.Text, v.FontFace(ctx)).Size() + return s.X, s.Y +} + +func (v *TextView) Draw(img *gui.Image, ctx gui.AppContext) { + v.ViewTmpl.Draw(img, ctx) + s := ctx.MeasureString(v.Text, v.FontFace(ctx)) + img.DrawString(v.Text, v.FontFace(ctx), 0, -s.Min.Y, v.Foreground(ctx)) +}