Text input and display widgets for Ebitengine
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

734 lines
16 KiB

4 months ago
package messeji
import (
"image"
"image/color"
"strings"
"sync"
"unicode"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/inpututil"
"github.com/hajimehoshi/ebiten/v2/text"
"golang.org/x/image/font"
)
// Alignment specifies how text is aligned within the field.
type Alignment int
const (
// AlignStart aligns text at the start of the field.
AlignStart Alignment = 0
// AlignCenter aligns text at the center of the field.
AlignCenter Alignment = 1
// AlignEnd aligns text at the end of the field.
AlignEnd Alignment = 2
)
4 months ago
const (
initialPadding = 5
4 months ago
initialScrollWidth = 32
)
var (
initialForeground = color.RGBA{0, 0, 0, 255}
initialBackground = color.RGBA{255, 255, 255, 255}
)
// TextField is a text display field. Call Update and Draw when your Game's
// Update and Draw methods are called.
//
// Note: A position and size must be set via SetRect before the field will appear.
// Keyboard events are not handled by default, and may be enabled via SetHandleKeyboard.
type TextField struct {
// r specifies the position and size of the field.
r image.Rectangle
// buffer is the actual content of the field.
buffer string
// prefix is the text shown before the content of the field.
prefix string
// suffix is the text shown after the content of the field.
suffix string
// wordWrap determines whether content is wrapped at word boundaries.
wordWrap bool
// bufferWrapped is the content of the field as it appears on the screen.
bufferWrapped []string
// bufferSize is the size (in pixels) of the entire text buffer. When single
// line mode is enabled,
bufferSize int
// lineWidths is the size (in pixels) of each line as it appears on the screen.
lineWidths []int
4 months ago
// singleLine is whether the field displays all text on a single line.
singleLine bool
// horizontal is the horizontal alignment of the text within field.
horizontal Alignment
// vertical is the vertical alignment of the text within field.
vertical Alignment
4 months ago
// face is the font face of the text within the field.
face font.Face
// lineHeight is the height of a single line of text.
lineHeight int
// overrideLineHeight is the custom height for a line of text, or 0 to disable.
overrideLineHeight int
// textColor is the color of the text within the field.
textColor color.Color
// backgroundColor is the color of the background of the field.
backgroundColor color.Color
// padding is the amount of padding around the text within the field.
padding int
// follow determines whether the field should automatically scroll to the
// end when content is added to the buffer.
follow bool
// overflow is whether the content of the field is currently larger than the field.
overflow bool
// offset is the current view offset of the text within the field.
offset int
// handleKeyboard is a flag which, when enabled, causes keyboard input to be handled.
handleKeyboard bool
// modified is a flag which, when enabled, causes bufferModified to be called
// during the next Draw call.
modified bool
// scrollRect specifies the position and size of the scrolling area.
scrollRect image.Rectangle
// scrollWidth is the width of the scroll bar.
scrollWidth int
// scrollVisible is whether the scroll bar is visible on the screen.
scrollVisible bool
// scrollAutoHide is whether the scroll bar should be automatically hidden
// when the entire text buffer fits within the screen.
scrollAutoHide bool
// scrollDrag is whether the scroll bar is currently being dragged.
scrollDrag bool
// img is the image of the field.
img *ebiten.Image
// visible is whether the field is visible on the screen.
visible bool
sync.Mutex
}
// NewTextField returns a new TextField. See type documentation for more info.
func NewTextField(face font.Face) *TextField {
f := &TextField{
face: face,
textColor: initialForeground,
backgroundColor: initialBackground,
padding: initialPadding,
scrollWidth: initialScrollWidth,
follow: true,
wordWrap: true,
scrollVisible: true,
scrollAutoHide: true,
visible: true,
}
f.setDefaultLineHeight()
return f
}
// Rect returns the position and size of the field.
func (f *TextField) Rect() image.Rectangle {
f.Lock()
defer f.Unlock()
return f.r
}
// SetRect sets the position and size of the field.
func (f *TextField) SetRect(r image.Rectangle) {
f.Lock()
defer f.Unlock()
f.r = r
f.drawImage()
}
// Text returns the text in the field.
func (f *TextField) Text() string {
f.Lock()
defer f.Unlock()
return f.buffer
}
// SetText sets the text in the field.
func (f *TextField) SetText(text string) {
f.Lock()
defer f.Unlock()
f.buffer = text
f.modified = true
}
// SetPrefix sets the text shown before the content of the field.
func (f *TextField) SetPrefix(text string) {
f.Lock()
defer f.Unlock()
f.prefix = text
f.drawImage()
}
// SetSuffix sets the text shown before the content of the field.
func (f *TextField) SetSuffix(text string) {
f.Lock()
defer f.Unlock()
f.suffix = text
f.drawImage()
}
// SetFollow sets whether the field should automatically scroll to the end when
// content is added to the buffer.
func (f *TextField) SetFollow(follow bool) {
f.Lock()
defer f.Unlock()
f.follow = follow
}
// SetSingleLine sets whether the field displays all text on a single line.
// When enabled, the field scrolls horizontally. Otherwise, it scrolls vertically.
func (f *TextField) SetSingleLine(single bool) {
f.Lock()
defer f.Unlock()
if f.singleLine == single {
return
}
f.singleLine = single
f.bufferModified()
}
// SetHorizontal sets the horizontal alignment of the text within the field.
func (f *TextField) SetHorizontal(h Alignment) {
f.Lock()
defer f.Unlock()
if f.horizontal == h {
return
}
f.horizontal = h
f.bufferModified()
}
// SetVertical sets the veritcal alignment of the text within the field.
func (f *TextField) SetVertical(v Alignment) {
f.Lock()
defer f.Unlock()
if f.vertical == v {
return
}
f.vertical = v
f.bufferModified()
}
4 months ago
// LineHeight returns the line height for the field.
func (f *TextField) LineHeight() int {
f.Lock()
defer f.Unlock()
if f.overrideLineHeight != 0 {
return f.overrideLineHeight
}
return f.lineHeight
}
// SetLineHeight sets a custom line height for the field. Setting a line
// height of 0 restores the automatic line height detection based on the font.
func (f *TextField) SetLineHeight(height int) {
f.Lock()
defer f.Unlock()
f.overrideLineHeight = height
}
// SetForegroundColor sets the color of the text within the field.
func (f *TextField) SetForegroundColor(c color.Color) {
f.Lock()
defer f.Unlock()
f.textColor = c
}
// SetBackgroundColor sets the color of the background of the field.
func (f *TextField) SetBackgroundColor(c color.Color) {
f.Lock()
defer f.Unlock()
f.backgroundColor = c
4 months ago
}
// SetFont sets the font face of the text within the field.
func (f *TextField) SetFont(face font.Face) {
f.Lock()
defer f.Unlock()
f.face = face
f.setDefaultLineHeight()
}
// Padding returns the amount of padding around the text within the field.
func (f *TextField) Padding() int {
f.Lock()
defer f.Unlock()
return f.padding
}
// SetPadding sets the amount of padding around the text within the field.
func (f *TextField) SetPadding(padding int) {
f.Lock()
defer f.Unlock()
f.padding = padding
}
// Visible returns whether the field is currently visible on the screen.
func (f *TextField) Visible() bool {
return f.visible
}
// SetVisible sets whether the field is visible on the screen.
func (f *TextField) SetVisible(visible bool) {
f.Lock()
defer f.Unlock()
if f.visible == visible {
return
}
f.visible = visible
if visible {
f.drawImage()
}
}
// SetScrollBarWidth sets the width of the scroll bar.
func (f *TextField) SetScrollBarWidth(width int) {
f.Lock()
defer f.Unlock()
if f.scrollWidth == width {
return
}
f.scrollWidth = width
f.drawImage()
}
// SetScrollBarVisible sets whether the scroll bar is visible on the screen.
func (f *TextField) SetScrollBarVisible(scrollVisible bool) {
f.Lock()
defer f.Unlock()
if f.scrollVisible == scrollVisible {
return
}
f.scrollVisible = scrollVisible
f.drawImage()
}
// SetAutoHideScrollBar sets whether the scroll bar is automatically hidden
// when the entire text buffer is visible.
func (f *TextField) SetAutoHideScrollBar(autoHide bool) {
f.Lock()
defer f.Unlock()
if f.scrollAutoHide == autoHide {
return
}
f.scrollAutoHide = autoHide
f.drawImage()
}
// WordWrap returns the current text wrap mode.
func (f *TextField) WordWrap() bool {
f.Lock()
defer f.Unlock()
return f.wordWrap
}
// SetWordWrap sets a flag which, when enabled, causes text to wrap without breaking words.
func (f *TextField) SetWordWrap(wrap bool) {
f.Lock()
defer f.Unlock()
if f.wordWrap == wrap {
return
}
f.wordWrap = wrap
f.drawImage()
}
// SetHandleKeyboard sets a flag controlling whether keyboard input should be handled
// by the field. This can be used to facilitate focus changes between multiple inputs.
func (f *TextField) SetHandleKeyboard(handle bool) {
f.Lock()
defer f.Unlock()
f.handleKeyboard = handle
}
// Write writes to the field's buffer.
func (f *TextField) Write(p []byte) (n int, err error) {
f.Lock()
defer f.Unlock()
f.buffer += string(p)
f.modified = true
return len(p), nil
}
// Update updates the field. This function should be called when
// Game.Update is called.
func (f *TextField) Update() error {
f.Lock()
defer f.Unlock()
if !f.visible || rectIsZero(f.r) {
return nil
}
var redraw bool
// Handle keyboard PageUp/PageDown.
if f.handleKeyboard {
offsetAmount := 0
if inpututil.IsKeyJustPressed(ebiten.KeyPageUp) {
offsetAmount = -100
} else if inpututil.IsKeyJustPressed(ebiten.KeyPageDown) {
offsetAmount = 100
}
if offsetAmount != 0 {
f.offset += offsetAmount
f.clampOffset()
redraw = true
}
}
// Handle mouse wheel.
_, scrollY := ebiten.Wheel()
if scrollY != 0 {
x, y := ebiten.CursorPosition()
p := image.Point{x, y}
if p.In(f.r) {
const offsetAmount = 25
f.offset -= int(scrollY * offsetAmount)
f.clampOffset()
redraw = true
}
}
// Handle scroll bar click (and drag).
if f.showScrollBar() {
if ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) || f.scrollDrag {
x, y := ebiten.CursorPosition()
p := image.Point{x - f.r.Min.X, y - f.r.Min.Y}
if f.scrollDrag || p.In(f.scrollRect) {
dragY := y - f.r.Min.Y - f.scrollWidth/4
if dragY < 0 {
dragY = 0
} else if dragY > f.scrollRect.Dy() {
dragY = f.scrollRect.Dy()
}
pct := float64(dragY) / float64(f.scrollRect.Dy()-f.scrollWidth/2)
if pct > 1 {
pct = 1
}
h := f.r.Dy()
f.offset = int(float64(f.bufferSize-h) * pct)
redraw = true
f.scrollDrag = true
}
if !ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) {
f.scrollDrag = false
}
}
}
if redraw {
f.drawImage()
}
return nil
}
// Draw draws the field on the screen. This function should be called
// when Game.Draw is called.
func (f *TextField) Draw(screen *ebiten.Image) {
f.Lock()
defer f.Unlock()
if f.modified {
f.bufferModified()
f.modified = false
}
if !f.visible || rectIsZero(f.r) || f.img == nil {
return
}
op := &ebiten.DrawImageOptions{}
op.GeoM.Translate(float64(f.r.Min.X), float64(f.r.Min.Y))
screen.DrawImage(f.img, op)
}
func (f *TextField) setDefaultLineHeight() {
lineBounds := text.BoundString(f.face, "ATZgpq.")
f.lineHeight = lineBounds.Dy() + 5
}
func (f *TextField) wrapContent(withScrollBar bool) {
f.lineWidths = f.lineWidths[:0]
4 months ago
buffer := f.prefix + f.buffer + f.suffix
if f.singleLine {
buffer = strings.ReplaceAll(buffer, "\n", "")
bounds := text.BoundString(f.face, buffer)
f.bufferWrapped = []string{buffer}
f.lineWidths = append(f.lineWidths, bounds.Dx())
4 months ago
return
}
w := f.r.Dx()
if withScrollBar {
w -= f.scrollWidth
}
f.bufferWrapped = f.bufferWrapped[:0]
for _, line := range strings.Split(buffer, "\n") {
// BoundString returns 0 for strings containing only whitespace.
if strings.TrimSpace(line) == "" {
f.bufferWrapped = append(f.bufferWrapped, "")
f.lineWidths = append(f.lineWidths, 0)
continue
}
4 months ago
l := len(line)
var start int
var end int
var initialEnd int
4 months ago
for start < l {
for end = l; end > start; end-- {
initialEnd = end
4 months ago
bounds := text.BoundString(f.face, line[start:end])
if bounds.Dx() < w-(f.padding*2)-2 {
if f.wordWrap {
if end < l && !unicode.IsSpace(rune(line[end])) {
for endOffset := 0; endOffset < end-start; endOffset++ {
if unicode.IsSpace(rune(line[end-endOffset])) {
end = end - endOffset
if end < l-1 {
end++
}
4 months ago
break
}
}
}
}
if end != initialEnd && f.horizontal != AlignStart {
bounds = text.BoundString(f.face, line[start:end])
}
4 months ago
f.bufferWrapped = append(f.bufferWrapped, line[start:end])
f.lineWidths = append(f.lineWidths, bounds.Dx())
4 months ago
break
}
}
start = end
}
}
}
// drawContent draws the text buffer to img.
func (f *TextField) drawContent() (overflow bool) {
f.img.Fill(f.backgroundColor)
fieldWidth := f.r.Dx()
fieldHeight := f.r.Dy()
if f.showScrollBar() {
fieldWidth -= f.scrollWidth
}
lines := len(f.bufferWrapped)
4 months ago
h := f.r.Dy()
lineHeight := f.overrideLineHeight
if lineHeight == 0 {
lineHeight = f.lineHeight
}
lineOffset := lineHeight / 3
4 months ago
f.bufferSize = 0
for i, line := range f.bufferWrapped {
lineX := f.padding
lineY := f.padding
lineY += lineHeight*(i+1) - lineOffset
if f.singleLine {
bounds := text.BoundString(f.face, line)
f.bufferSize = bounds.Dx() + f.padding*4
} else {
f.bufferSize = lineY + lineOffset + f.padding
}
if lineY < 0 || lineY >= h-(f.padding*2) {
overflow = true
}
// Skip drawing off-screen lines.
if lineY < 0 || lineY-lineHeight > f.offset+h {
continue
}
if f.singleLine {
lineX -= f.offset
} else {
lineY -= f.offset
}
if f.horizontal == AlignCenter {
lineX = (fieldWidth - f.lineWidths[i]) / 2
} else if f.horizontal == AlignEnd {
lineX = (fieldWidth - f.lineWidths[i]) - f.padding*2
}
if f.vertical == AlignCenter && lineHeight*lines <= h {
lineY = (fieldHeight-(lineHeight*lines))/2 + lineHeight*(i+1) - lineOffset
} else if f.vertical == AlignEnd && lineHeight*lines <= h {
lineY = (fieldHeight - lineHeight*i) - f.padding*2
}
4 months ago
text.Draw(f.img, line, f.face, lineX, lineY, f.textColor)
}
return overflow
}
func (f *TextField) clampOffset() {
fieldSize := f.r.Dy()
if f.singleLine {
fieldSize = f.r.Dx()
}
if f.offset > f.bufferSize-fieldSize {
f.offset = f.bufferSize - fieldSize
}
if f.offset < 0 {
f.offset = 0
}
}
func (f *TextField) showScrollBar() bool {
return !f.singleLine && f.scrollVisible && (f.overflow || !f.scrollAutoHide)
}
// drawImage draws the field to img (caching it for future draws).
func (f *TextField) drawImage() {
if rectIsZero(f.r) {
f.img = nil
return
}
w, h := f.r.Dx(), f.r.Dy()
var newImage bool
if f.img == nil {
newImage = true
} else {
imgRect := f.img.Bounds()
imgW, imgH := imgRect.Dx(), imgRect.Dy()
newImage = imgW != w || imgH != h
}
if newImage {
f.img = ebiten.NewImage(w, h)
}
f.wrapContent(false)
f.overflow = f.drawContent()
if f.showScrollBar() {
f.wrapContent(true)
f.drawContent()
}
// Draw scrollbar.
if f.showScrollBar() {
scrollAreaX, scrollAreaY := w-f.scrollWidth, 0
f.scrollRect = image.Rect(scrollAreaX, scrollAreaY, scrollAreaX+f.scrollWidth, h)
4 months ago
scrollBarH := f.scrollWidth / 2
if scrollBarH < 4 {
scrollBarH = 4
}
scrollX, scrollY := w-f.scrollWidth, 0
4 months ago
pct := float64(f.offset) / float64(f.bufferSize-h)
scrollY += int(float64(h-scrollBarH) * pct)
scrollBarRect := image.Rect(scrollX, scrollY, scrollX+f.scrollWidth, scrollY+scrollBarH)
4 months ago
f.img.SubImage(f.scrollRect).(*ebiten.Image).Fill(color.RGBA{200, 200, 200, 255})
f.img.SubImage(scrollBarRect).(*ebiten.Image).Fill(color.RGBA{108, 108, 108, 255})
}
}
func (f *TextField) bufferModified() {
f.drawImage()
if !f.follow {
return
}
fieldSize := f.r.Dy()
if f.singleLine {
fieldSize = f.r.Dx()
}
offset := f.bufferSize - fieldSize
if offset < 0 {
offset = 0
}
if offset != f.offset {
f.offset = offset
f.drawImage()
}
}
func rectIsZero(r image.Rectangle) bool {
return r == image.Rectangle{}
}