diff --git a/ext/text/assets/anchor_button_bottom.png b/ext/text/assets/anchor_button_bottom.png new file mode 100644 index 0000000..763a3dd Binary files /dev/null and b/ext/text/assets/anchor_button_bottom.png differ diff --git a/ext/text/assets/anchor_button_bottom_left.png b/ext/text/assets/anchor_button_bottom_left.png new file mode 100644 index 0000000..d5b6f05 Binary files /dev/null and b/ext/text/assets/anchor_button_bottom_left.png differ diff --git a/ext/text/assets/anchor_button_bottom_right.png b/ext/text/assets/anchor_button_bottom_right.png new file mode 100644 index 0000000..89cf72a Binary files /dev/null and b/ext/text/assets/anchor_button_bottom_right.png differ diff --git a/ext/text/assets/anchor_button_center.png b/ext/text/assets/anchor_button_center.png new file mode 100644 index 0000000..ca31b89 Binary files /dev/null and b/ext/text/assets/anchor_button_center.png differ diff --git a/ext/text/assets/anchor_button_left.png b/ext/text/assets/anchor_button_left.png new file mode 100644 index 0000000..808fdfb Binary files /dev/null and b/ext/text/assets/anchor_button_left.png differ diff --git a/ext/text/assets/anchor_button_right.png b/ext/text/assets/anchor_button_right.png new file mode 100644 index 0000000..8f195fc Binary files /dev/null and b/ext/text/assets/anchor_button_right.png differ diff --git a/ext/text/assets/anchor_button_top.png b/ext/text/assets/anchor_button_top.png new file mode 100644 index 0000000..aabd88d Binary files /dev/null and b/ext/text/assets/anchor_button_top.png differ diff --git a/ext/text/assets/anchor_button_top_left.png b/ext/text/assets/anchor_button_top_left.png new file mode 100644 index 0000000..f3f320e Binary files /dev/null and b/ext/text/assets/anchor_button_top_left.png differ diff --git a/ext/text/assets/anchor_button_top_right.png b/ext/text/assets/anchor_button_top_right.png new file mode 100644 index 0000000..6bc26a3 Binary files /dev/null and b/ext/text/assets/anchor_button_top_right.png differ diff --git a/ext/text/gui/gui.go b/ext/text/gui/gui.go new file mode 100644 index 0000000..41ea9b5 --- /dev/null +++ b/ext/text/gui/gui.go @@ -0,0 +1,165 @@ +package gui + +import ( + "image/png" + "path/filepath" + "runtime" + + "github.com/gopxl/pixel/v2" + "github.com/gopxl/pixel/v2/backends/opengl" + "github.com/gopxl/pixel/v2/ext/atlas" + "github.com/gopxl/pixelui/v2" + "github.com/inkyblackness/imgui-go/v4" +) + +var basepath string = getBasepath() + +type MenuOptions struct { + Padding pixel.Vec + Height float64 + + SetAnchor func(anchor pixel.Anchor) + UnsetAnchor func() + Reset func() +} + +func NewMenu(win *opengl.Window, textures *atlas.Atlas, options MenuOptions) *Menu { + basepath = getBasepath() + var ( + topLeft = filepath.Join(basepath, "assets/anchor_button_top_left.png") + top = filepath.Join(basepath, "assets/anchor_button_top.png") + topRight = filepath.Join(basepath, "assets/anchor_button_top_right.png") + left = filepath.Join(basepath, "assets/anchor_button_left.png") + center = filepath.Join(basepath, "assets/anchor_button_center.png") + right = filepath.Join(basepath, "assets/anchor_button_right.png") + bottomLeft = filepath.Join(basepath, "assets/anchor_button_bottom_left.png") + bottom = filepath.Join(basepath, "assets/anchor_button_bottom.png") + bottomRight = filepath.Join(basepath, "assets/anchor_button_bottom_right.png") + ) + if options.Height == 0 { + options.Height = 32 + } + + guiGroup := textures.MakeGroup() + menu := &Menu{ + win: win, + options: options, + anchorButtons: []*anchorButton{ + // Pixel anchors describe the direction in which the anchored item will me moved, + // so we take the opposite in order to describe the position of the anchor itself. + {guiGroup.AddFile(topLeft, png.Decode), pixel.TopLeft.Opposite()}, + {guiGroup.AddFile(top, png.Decode), pixel.Top.Opposite()}, + {guiGroup.AddFile(topRight, png.Decode), pixel.TopRight.Opposite()}, + {guiGroup.AddFile(left, png.Decode), pixel.Left.Opposite()}, + {guiGroup.AddFile(center, png.Decode), pixel.Center}, + {guiGroup.AddFile(right, png.Decode), pixel.Right.Opposite()}, + {guiGroup.AddFile(bottomLeft, png.Decode), pixel.BottomLeft.Opposite()}, + {guiGroup.AddFile(bottom, png.Decode), pixel.Bottom.Opposite()}, + {guiGroup.AddFile(bottomRight, png.Decode), pixel.BottomRight.Opposite()}, + }, + } + textures.Pack() + return menu +} + +// Menu has controls anchoring and reseting text +type Menu struct { + win *opengl.Window + options MenuOptions + + anchorButtons []*anchorButton + selected *anchorButton +} + +// Update is called every frame to redraw UI elements in the window +func (m *Menu) Update() { + imgui.PushStyleVarVec2(imgui.StyleVarWindowPadding, pixelui.IVec(m.options.Padding)) + imgui.SetNextWindowPos(pixelui.IVec(pixel.V(0, 0))) + imgui.SetNextWindowSize(pixelui.IVec(m.size())) + imgui.BeginV("Menu", nil, imgui.WindowFlagsNoDecoration) + { + m.anchorDropdown() + m.resetButton() + } + imgui.End() + imgui.PopStyleVarV(1) +} + +func (m *Menu) size() pixel.Vec { + return pixel.V(m.win.Bounds().Size().X, m.options.Height) +} + +func (m *Menu) buttonHeight() float32 { + return float32(m.options.Height - 2*m.options.Padding.Y) +} + +// anchorDropdown manages the anchor button popup window +func (m *Menu) anchorDropdown() { + size := imgui.Vec2{X: 64, Y: m.buttonHeight()} + imgui.ButtonV("Anchor", size) + imgui.SetNextWindowPos(imgui.Vec2{X: float32(m.options.Padding.X), Y: float32(m.options.Padding.Y) + size.Y + 4}) + imgui.OpenPopupOnItemClickV("Anchors", 0) + if imgui.BeginPopup("Anchors") { + for i, anchorButton := range m.anchorButtons { + if i%3 != 0 { + imgui.SameLineV(0, 4) + } + active := anchorButton == m.selected + if anchorButton.add(active) { + if active { + m.selected = nil + m.options.UnsetAnchor() + } else { + m.selected = anchorButton + m.options.SetAnchor(anchorButton.anchor) + } + } + } + imgui.EndPopup() + } +} + +func (m *Menu) resetButton() { + size := imgui.Vec2{X: 48, Y: m.buttonHeight()} + imgui.SameLine() + imgui.PushStyleColor(imgui.StyleColorButton, pixelui.ColorA(255, 0, 0, 200)) + imgui.PushStyleColor(imgui.StyleColorButtonHovered, pixelui.ColorA(220, 0, 0, 200)) + imgui.PushStyleColor(imgui.StyleColorButtonActive, pixelui.ColorA(175, 0, 0, 200)) + if imgui.ButtonV("Reset", size) { + m.selected = nil + m.options.Reset() + } + imgui.PopStyleColorV(3) +} + +type anchorButton struct { + textureId atlas.TextureId + anchor pixel.Anchor +} + +func (ab *anchorButton) add(active bool) bool { + var color imgui.Vec4 + if active { + color = pixelui.ColorA(255, 255, 255, 100) + } else { + color = pixelui.Color(255, 255, 255) + } + + if imgui.ImageButtonV( + imgui.TextureID(ab.textureId.ID()), + pixelui.IVec(ab.textureId.Bounds().Size()), + imgui.Vec2{X: 0, Y: 0}, + imgui.Vec2{X: 1, Y: 1}, + 0, + imgui.Vec4{X: 0, Y: 0, Z: 0, W: 0}, + color, + ) { + return true + } + return false +} + +func getBasepath() string { + _, b, _, _ := runtime.Caller(0) + return filepath.ToSlash(filepath.Dir(filepath.Dir(b))) +} diff --git a/ext/text/main.go b/ext/text/main.go new file mode 100644 index 0000000..ca4b663 --- /dev/null +++ b/ext/text/main.go @@ -0,0 +1,246 @@ +package main + +import ( + "image/color" + "time" + "unicode" + + "github.com/golang/freetype/truetype" + "github.com/gopxl/pixel/v2" + "github.com/gopxl/pixel/v2/backends/opengl" + "github.com/gopxl/pixel/v2/ext/atlas" + "github.com/gopxl/pixel/v2/ext/imdraw" + "github.com/gopxl/pixel/v2/ext/text" + "github.com/gopxl/pixelui/v2" + "golang.org/x/image/colornames" + "golang.org/x/image/font" + "golang.org/x/image/font/gofont/goregular" + + "github.com/gopxl/pixel-examples/ext/text/gui" +) + +var ( + textures atlas.Atlas + fontAtlas = text.NewAtlas( + ttfFromBytesMust(goregular.TTF, 42), + text.ASCII, + text.RangeTable(unicode.Latin), + ) +) + +func run() { + title := "Text example" + cfg := opengl.WindowConfig{ + Title: title, + Bounds: pixel.R(0, 0, 1024, 768), + } + win, err := opengl.NewWindow(cfg) + if err != nil { + panic(err) + } + defer win.Destroy() + + origin := win.Bounds().Center() + txt := text.New(origin, fontAtlas) + txtBox := newTextBox(txt) + txtBox.txt.LineHeight *= 1.5 + + // Create UI controls + menu := gui.NewMenu(win, &textures, gui.MenuOptions{ + Height: 32.0, + Padding: pixel.V(4, 4), + SetAnchor: txtBox.setAnchor, + UnsetAnchor: txtBox.unsetAnchor, + Reset: txtBox.reset, + }) + ui := pixelui.New(win, &textures, 0) + + for !win.Closed() { + win.Clear(colornames.Skyblue) + + // Draw UI + ui.NewFrame() + menu.Update() + ui.Draw(win) + + // Draw text box + txtBox.process(win) + txtBox.draw(win) + + win.Update() + } +} + +func newCursor(color color.Color, pos pixel.Vec, thickness, lineHeight float64) *cursor { + imd := imdraw.New(nil) + imd.Color = color + imd.Push(pixel.ZV, pixel.V(0, lineHeight)) + imd.Line(thickness) + return &cursor{ + imd: imd, + pos: pos, + offset: pixel.V(6, -2), + blink: time.NewTicker(time.Second), + visible: true, + } +} + +// cursor draws a blinking text cursor at the current Dot position of the text +type cursor struct { + imd *imdraw.IMDraw + pos pixel.Vec + blink *time.Ticker + visible bool + offset pixel.Vec +} + +func (c *cursor) setPos(pos pixel.Vec) { + if !c.pos.Eq(pos) { + c.pos = pos + c.blink.Reset(time.Second) + c.visible = true + } +} + +func (c *cursor) draw(win *opengl.Window) { + select { + case <-c.blink.C: + c.visible = !c.visible + default: + } + if c.visible { + win.SetMatrix(pixel.IM.Moved(c.pos.Add(c.offset))) + c.imd.Draw(win) + win.SetMatrix(pixel.IM) + } +} + +func newTextBox(txt *text.Text) *textBox { + return &textBox{ + txt: txt, + cursor: newCursor(pixel.RGB(1, 1, 1), txt.Orig, 2, txt.LineHeight), + imd: imdraw.New(nil), + dirty: true, + } +} + +// textBox manages updating and anchoring text +type textBox struct { + txt *text.Text + cursor *cursor + imd *imdraw.IMDraw + anchor pixel.Anchor + + s string + dirty bool +} + +func (tb *textBox) process(win *opengl.Window) { + typed := win.Typed() + if typed != "" { + tb.writeString(typed) + } + if win.JustPressed(pixel.KeyTab) || win.Repeated(pixel.KeyTab) { + tb.writeRune('\t') + } + if win.JustPressed(pixel.KeyEnter) || + win.Repeated(pixel.KeyEnter) || + win.JustPressed(pixel.KeyKPEnter) || + win.Repeated(pixel.KeyKPEnter) { + tb.writeRune('\n') + } + if win.JustPressed(pixel.KeyBackspace) || win.Repeated(pixel.KeyBackspace) { + tb.delete() + } + + if tb.dirty { + tb.cursor.setPos(tb.txt.AnchoredDot()) + } +} + +func (tb *textBox) writeString(s string) (n int, err error) { + tb.s += s + tb.dirty = true + return tb.txt.WriteString(s) +} + +func (tb *textBox) writeRune(r rune) (n int, err error) { + tb.s += string(r) + tb.dirty = true + return tb.txt.WriteRune(r) +} + +func (tb *textBox) reset() { + tb.dirty = true + tb.s = "" + tb.txt.Clear() + tb.txt.Unaligned() +} + +func (tb *textBox) unsetAnchor() { + tb.txt.Unaligned() + tb.dirty = true +} + +func (tb *textBox) delete() { + if len(tb.s) == 0 { + return + } + tb.dirty = true + tb.s = tb.s[:len(tb.s)-1] + tb.txt.Clear() + tb.txt.WriteString(tb.s) +} + +func (tb *textBox) draw(win *opengl.Window) { + if tb.dirty { + tb.redraw() + } + tb.txt.Draw(win, pixel.IM) + tb.imd.Draw(win) + tb.cursor.draw(win) +} + +// redraw updates imdraw elements when the text has changed +func (tb *textBox) redraw() { + tb.imd.Clear() + + // Bounds + tb.imd.Color = colornames.Red + bounds := tb.txt.AnchoredBounds() + tb.imd.Push(bounds.Min, bounds.Max) + tb.imd.Rectangle(2) + + // Origin + tb.imd.Color = colornames.Blue + tb.imd.Push(tb.txt.Orig) + tb.imd.Circle(3, 1) + + // Dot + tb.imd.Color = colornames.Green + tb.imd.Push(tb.txt.AnchoredDot()) + tb.imd.Circle(3, 1) + + tb.dirty = false +} + +func (tb *textBox) setAnchor(anchor pixel.Anchor) { + tb.txt.AlignedTo(anchor) + tb.anchor = anchor + tb.dirty = true +} + +func ttfFromBytesMust(b []byte, size float64) font.Face { + ttf, err := truetype.Parse(b) + if err != nil { + panic(err) + } + return truetype.NewFace(ttf, &truetype.Options{ + Size: size, + GlyphCacheEntries: 1, + }) +} + +func main() { + opengl.Run(run) +}