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.
733 lines
16 KiB
733 lines
16 KiB
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 |
|
) |
|
|
|
const ( |
|
initialPadding = 5 |
|
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 |
|
|
|
// 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 |
|
|
|
// 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() |
|
} |
|
|
|
// 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 |
|
} |
|
|
|
// 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] |
|
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()) |
|
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 |
|
} |
|
|
|
l := len(line) |
|
var start int |
|
var end int |
|
var initialEnd int |
|
for start < l { |
|
for end = l; end > start; end-- { |
|
initialEnd = end |
|
|
|
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++ |
|
} |
|
break |
|
} |
|
} |
|
} |
|
} |
|
|
|
if end != initialEnd && f.horizontal != AlignStart { |
|
bounds = text.BoundString(f.face, line[start:end]) |
|
} |
|
|
|
f.bufferWrapped = append(f.bufferWrapped, line[start:end]) |
|
f.lineWidths = append(f.lineWidths, bounds.Dx()) |
|
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) |
|
|
|
h := f.r.Dy() |
|
lineHeight := f.overrideLineHeight |
|
if lineHeight == 0 { |
|
lineHeight = f.lineHeight |
|
} |
|
lineOffset := lineHeight / 3 |
|
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 |
|
} |
|
|
|
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) |
|
|
|
scrollBarH := f.scrollWidth / 2 |
|
if scrollBarH < 4 { |
|
scrollBarH = 4 |
|
} |
|
|
|
scrollX, scrollY := w-f.scrollWidth, 0 |
|
pct := float64(f.offset) / float64(f.bufferSize-h) |
|
scrollY += int(float64(h-scrollBarH) * pct) |
|
scrollBarRect := image.Rect(scrollX, scrollY, scrollX+f.scrollWidth, scrollY+scrollBarH) |
|
|
|
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{} |
|
}
|
|
|