Move kibodo into etk

This commit is contained in:
Trevor Slocum 2024-01-16 12:52:24 -08:00
parent fe71e0974a
commit d4e4ef831f
15 changed files with 2066 additions and 10 deletions

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.idea
*.sh
*.wasm

57
flex.go
View File

@ -6,12 +6,13 @@ import (
"github.com/hajimehoshi/ebiten/v2"
)
// Flex is a flexible stack-based layout. Each Flex widget may be oriented
// horizontally or vertically.
// Flex is a flexbox layou which may be oriented horizontally or vertically.
type Flex struct {
*Box
vertical bool
vertical bool
childWidth, childHeight int
columnGap, rowGap int
modified bool
}
// NewFlex returns a new Flex widget.
@ -27,7 +28,33 @@ func (f *Flex) SetRect(r image.Rectangle) {
defer f.Unlock()
f.Box.rect = r
f.reposition()
f.modified = true
}
// SetGapSize sets the gap between child in the Flex.
func (f *Flex) SetGapSize(columnGap int, rowGap int) {
f.Lock()
defer f.Unlock()
if f.columnGap == columnGap && f.rowGap == rowGap {
return
}
f.columnGap, f.rowGap = columnGap, rowGap
f.modified = true
}
// SetChildSize sets the size of each child in the Flex.
func (f *Flex) SetChildSize(width int, height int) {
f.Lock()
defer f.Unlock()
if f.childWidth == width && f.childHeight == height {
return
}
f.childWidth, f.childHeight = width, height
f.modified = true
}
// SetVertical sets the orientation of the child widget stacking.
@ -40,7 +67,16 @@ func (f *Flex) SetVertical(v bool) {
}
f.vertical = v
f.reposition()
f.modified = true
}
// AddChild adds a child to the widget.
func (f *Flex) AddChild(w ...Widget) {
f.Lock()
defer f.Unlock()
f.children = append(f.children, w...)
f.modified = true
}
// HandleKeyboard is called when a keyboard event occurs.
@ -58,6 +94,11 @@ func (f *Flex) Draw(screen *ebiten.Image) error {
f.Lock()
defer f.Unlock()
if f.modified {
f.reposition()
f.modified = false
}
for _, child := range f.children {
err := child.Draw(screen)
if err != nil {
@ -72,6 +113,10 @@ func (f *Flex) reposition() {
l := len(f.children)
r := f.rect
// flexbox
// gap
// orientation
if f.vertical {
childHeight := float64(r.Dy()) / float64(l)

1
go.mod
View File

@ -3,7 +3,6 @@ module code.rocket9labs.com/tslocum/etk
go 1.18
require (
code.rocketnine.space/tslocum/kibodo v1.0.3-0.20240110043547-31f31eb07497
code.rocketnine.space/tslocum/messeji v1.0.6-0.20240109205105-4ffeffdd2441
github.com/hajimehoshi/ebiten/v2 v2.6.3
github.com/llgcode/draw2d v0.0.0-20231212091825-f55e0c776b44

2
go.sum
View File

@ -1,5 +1,3 @@
code.rocketnine.space/tslocum/kibodo v1.0.3-0.20240110043547-31f31eb07497 h1:QpzLvcDV7DsaeFKrQZcHkDfq1PqsHcwUVnRXRKBAxe0=
code.rocketnine.space/tslocum/kibodo v1.0.3-0.20240110043547-31f31eb07497/go.mod h1:U9KpaLHr7nPZ8XfRUI81uAQMrxob+bI3C/IXzeXe7gw=
code.rocketnine.space/tslocum/messeji v1.0.6-0.20240109205105-4ffeffdd2441 h1:pe5QsaN6Tvil0Y+jodbMLfdMWaL5MBb2vyorBIfQwqk=
code.rocketnine.space/tslocum/messeji v1.0.6-0.20240109205105-4ffeffdd2441/go.mod h1:cznUGfvC7BKbc5sx4I36XpLsF0ar3TPJYZlrND0IlDQ=
github.com/ebitengine/purego v0.5.1 h1:hNunhThpOf1vzKl49v6YxIsXLhl92vbBEv1/2Ez3ZrY=

View File

@ -3,7 +3,7 @@ package etk
import (
"image"
"code.rocketnine.space/tslocum/kibodo"
"code.rocket9labs.com/tslocum/etk/kibodo"
"github.com/hajimehoshi/ebiten/v2"
)

22
kibodo/README.md Normal file
View File

@ -0,0 +1,22 @@
# kibodo
[![GoDoc](https://code.rocketnine.space/tslocum/godoc-static/raw/branch/master/badge.svg)](https://docs.rocketnine.space/code.rocketnine.space/tslocum/etk/kibodo)
[![Donate via LiberaPay](https://img.shields.io/liberapay/receives/rocketnine.space.svg?logo=liberapay)](https://liberapay.com/rocketnine.space)
[![Donate via Patreon](https://img.shields.io/badge/dynamic/json?color=%23e85b46&label=Patreon&query=data.attributes.patron_count&suffix=%20patrons&url=https%3A%2F%2Fwww.patreon.com%2Fapi%2Fcampaigns%2F5252223)](https://www.patreon.com/rocketnine)
On-screen keyboard widget for [Ebitengine](https://github.com/hajimehoshi/ebiten)
## Demo
[**Try kibodo**](https://kibodo.rocketnine.space)
## Documentation
Documentation is available via [godoc](https://docs.rocketnine.space/code.rocketnine.space/tslocum/etk/kibodo).
## Support
Please share issues and suggestions [here](https://code.rocketnine.space/tslocum/etk/issues).
## Screenshot
[![Screenshot](https://code.rocketnine.space/tslocum/etk/raw/branch/main/kibodo/screenshot.png)](https://code.rocketnine.space/tslocum/etk/src/branch/main/etk/screenshot.png)

4
kibodo/doc.go Normal file
View File

@ -0,0 +1,4 @@
/*
Package kibodo provides an on-screen keyboard widget for Ebitengine.
*/
package kibodo

View File

@ -0,0 +1,120 @@
//go:build example
package game
import (
"fmt"
"code.rocket9labs.com/tslocum/etk/kibodo"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
"github.com/hajimehoshi/ebiten/v2/inpututil"
)
type game struct {
w, h int
k *kibodo.Keyboard
userInput []byte
incomingInput []*kibodo.Input
op *ebiten.DrawImageOptions
buffer *ebiten.Image
}
var spinner = []byte(`-\|/`)
// NewDemoGame returns a new kibodo demo game.
func NewDemoGame() *game {
k := kibodo.NewKeyboard()
k.SetAllowUserHide(true)
k.SetPassThroughPhysicalInput(true)
k.SetKeys(kibodo.KeysQWERTY)
k.SetHideShortcuts([]ebiten.Key{ebiten.KeyEscape})
g := &game{
k: k,
op: &ebiten.DrawImageOptions{
Filter: ebiten.FilterNearest,
},
}
go g.showKeyboard()
return g
}
func (g *game) showKeyboard() {
if g.k.Visible() {
return
}
// Clear current input
g.userInput = nil
// Show keyboard
g.k.Show()
}
func (g *game) Layout(outsideWidth, outsideHeight int) (int, int) {
s := ebiten.DeviceScaleFactor()
outsideWidth, outsideHeight = int(float64(outsideWidth)*s), int(float64(outsideHeight)*s)
if g.w == outsideWidth && g.h == outsideHeight {
return outsideWidth, outsideHeight
}
g.w, g.h = outsideWidth, outsideHeight
g.buffer = ebiten.NewImage(g.w, g.h)
y := 200
if g.h > g.w && (g.h-g.w) > 200 {
y = g.h - g.w
}
g.k.SetRect(0, y, g.w, g.h-y)
return outsideWidth, outsideHeight
}
func (g *game) Update() error {
if inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft) && !g.k.Visible() {
g.showKeyboard()
}
g.incomingInput = g.k.AppendInput(g.incomingInput[:0])
for _, input := range g.incomingInput {
if input.Rune > 0 {
g.userInput = append(g.userInput, []byte(string(input.Rune))...)
continue
}
if input.Key == ebiten.KeyBackspace {
s := string(g.userInput)
if len(s) > 0 {
g.userInput = []byte(s[:len(s)-1])
}
continue
} else if input.Key == ebiten.KeyEnter {
g.userInput = nil
continue
} else if input.Key < 0 {
continue
}
g.userInput = append(g.userInput, []byte("<"+input.Key.String()+">")...)
}
return g.k.Update()
}
func (g *game) Draw(screen *ebiten.Image) {
g.k.Draw(screen)
g.buffer.Clear()
ebitenutil.DebugPrint(g.buffer, fmt.Sprintf("FPS %0.0f\nTPS %0.0f\n\n%s", ebiten.ActualFPS(), ebiten.ActualTPS(), g.userInput))
g.op.GeoM.Reset()
g.op.GeoM.Translate(3, 0)
g.op.GeoM.Scale(2, 2)
screen.DrawImage(g.buffer, g.op)
}

View File

@ -0,0 +1,18 @@
//go:build example
package mobile
import (
"code.rocket9labs.com/tslocum/etk/kibodo/examples/kibodo/game"
"github.com/hajimehoshi/ebiten/v2/mobile"
)
func init() {
mobile.SetGame(game.NewDemoGame())
}
// Dummy is a dummy exported function.
//
// gomobile will only compile packages that include at least one exported function.
// Dummy forces gomobile to compile this package.
func Dummy() {}

View File

@ -0,0 +1,23 @@
//go:build example
package main
import (
"log"
"code.rocket9labs.com/tslocum/etk/kibodo/examples/kibodo/game"
"github.com/hajimehoshi/ebiten/v2"
)
func main() {
ebiten.SetWindowTitle("キーボード")
ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled)
ebiten.SetTPS(60)
ebiten.SetVsyncEnabled(true)
g := game.NewDemoGame()
if err := ebiten.RunGame(g); err != nil {
log.Fatal(err)
}
}

33
kibodo/key.go Normal file
View File

@ -0,0 +1,33 @@
package kibodo
import (
"github.com/hajimehoshi/ebiten/v2"
)
// Key represents a virtual key.
type Key struct {
LowerLabel string
UpperLabel string
LowerInput *Input
UpperInput *Input
Wide bool
x, y int
w, h int
pressed bool
pressedTouchID ebiten.TouchID
}
// Input represents the input event from a key press.
type Input struct {
Rune rune
Key ebiten.Key
}
func (i *Input) String() string {
if i.Rune > 0 {
return string(i.Rune)
}
return i.Key.String()
}

774
kibodo/keyboard.go Normal file
View File

@ -0,0 +1,774 @@
package kibodo
import (
"image"
"image/color"
"log"
"time"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/examples/resources/fonts"
"github.com/hajimehoshi/ebiten/v2/inpututil"
"github.com/hajimehoshi/ebiten/v2/text"
"golang.org/x/image/font"
"golang.org/x/image/font/opentype"
)
// Keyboard is an on-screen keyboard widget.
type Keyboard struct {
x, y int
w, h int
visible bool
alpha float64
passPhysical bool
allowUserHide bool
incomingBuffer []rune
inputEvents []*Input
keys [][]*Key
normalKeys [][]*Key
extendedKeys [][]*Key
showExtended bool
backgroundLower *ebiten.Image
backgroundUpper *ebiten.Image
backgroundDirty bool
op *ebiten.DrawImageOptions
backgroundColor color.Color
lastBackgroundColor color.Color
shift bool
touchIDs []ebiten.TouchID
holdTouchID ebiten.TouchID
holdKey *Key
wasPressed bool
hideShortcuts []ebiten.Key
labelFont font.Face
lineHeight int
lineOffset int
backspaceDelay time.Duration
backspaceRepeat time.Duration
backspaceLast time.Time
scheduleFrameFunc func()
}
// NewKeyboard returns a new Keyboard widget.
func NewKeyboard() *Keyboard {
fontFace, err := defaultFontFace(64)
if err != nil {
log.Fatal(err)
}
k := &Keyboard{
alpha: 1.0,
op: &ebiten.DrawImageOptions{
Filter: ebiten.FilterNearest,
},
keys: KeysQWERTY,
normalKeys: KeysQWERTY,
backgroundLower: ebiten.NewImage(1, 1),
backgroundUpper: ebiten.NewImage(1, 1),
backgroundColor: color.Black,
holdTouchID: -1,
labelFont: fontFace,
backspaceDelay: 500 * time.Millisecond,
backspaceRepeat: 75 * time.Millisecond,
}
k.fontUpdated()
return k
}
func defaultFont() (*opentype.Font, error) {
return opentype.Parse(fonts.MPlus1pRegular_ttf)
}
func defaultFontFace(size float64) (font.Face, error) {
f, err := defaultFont()
if err != nil {
return nil, err
}
const dpi = 72 // TODO
return opentype.NewFace(f, &opentype.FaceOptions{
Size: size,
DPI: dpi,
Hinting: font.HintingFull,
})
}
// SetRect sets the position and size of the widget.
func (k *Keyboard) SetRect(x, y, w, h int) {
if k.x == x && k.y == y && k.w == w && k.h == h {
return
}
k.x, k.y, k.w, k.h = x, y, w, h
k.updateKeyRects()
k.backgroundDirty = true
}
// Rect returns the position and size of the widget.
func (k *Keyboard) Rect() image.Rectangle {
return image.Rect(k.x, k.y, k.x+k.w, k.y+k.h)
}
// GetKeys returns the keys of the keyboard.
func (k *Keyboard) GetKeys() [][]*Key {
return k.keys
}
// SetKeys sets the keys of the keyboard.
func (k *Keyboard) SetKeys(keys [][]*Key) {
k.normalKeys = keys
if !k.showExtended && !keysEqual(keys, k.keys) {
k.keys = keys
k.updateKeyRects()
k.backgroundDirty = true
}
}
// SetExtendedKeys sets the keys of the keyboard when the .
func (k *Keyboard) SetExtendedKeys(keys [][]*Key) {
k.extendedKeys = keys
if k.showExtended && !keysEqual(keys, k.keys) {
k.keys = keys
k.updateKeyRects()
k.backgroundDirty = true
}
}
// SetShowExtended sets whether the normal or extended keyboard is shown.
func (k *Keyboard) SetShowExtended(show bool) {
if k.showExtended == show {
return
}
k.showExtended = show
if k.showExtended {
k.keys = k.extendedKeys
} else {
k.keys = k.normalKeys
}
k.updateKeyRects()
k.backgroundDirty = true
}
// SetLabelFont sets the key label font.
func (k *Keyboard) SetLabelFont(face font.Face) {
k.labelFont = face
k.fontUpdated()
k.backgroundDirty = true
}
func (k *Keyboard) fontUpdated() {
m := k.labelFont.Metrics()
k.lineHeight = m.Height.Round()
k.lineOffset = m.Ascent.Round()
}
// SetHideShortcuts sets the key shortcuts which, when pressed, will hide the
// keyboard.
func (k *Keyboard) SetHideShortcuts(shortcuts []ebiten.Key) {
k.hideShortcuts = shortcuts
}
func (k *Keyboard) updateKeyRects() {
if len(k.keys) == 0 {
return
}
maxCells := 0
for _, rowKeys := range k.keys {
if len(rowKeys) > maxCells {
maxCells = len(rowKeys)
}
}
// TODO user configurable
cellPaddingW := 1
cellPaddingH := 1
cellH := (k.h - (cellPaddingH * (len(k.keys) - 1))) / len(k.keys)
row := 0
x, y := 0, 0
for _, rowKeys := range k.keys {
if len(rowKeys) == 0 {
continue
}
availableWidth := k.w
for _, key := range rowKeys {
if key.Wide {
availableWidth = availableWidth / 2
break
}
}
cellW := (availableWidth - (cellPaddingW * (len(rowKeys) - 1))) / len(rowKeys)
x = 0
for i, key := range rowKeys {
key.w, key.h = cellW, cellH
key.x, key.y = x, y
if i == len(rowKeys)-1 {
key.w = k.w - key.x
}
if key.Wide {
key.w = k.w - k.w/2 + (cellW)
}
x += key.w
}
// Count non-empty rows only
row++
y += (cellH + cellPaddingH)
}
}
func (k *Keyboard) at(x, y int) *Key {
if !k.visible {
return nil
}
if x >= k.x && x <= k.x+k.w && y >= k.y && y <= k.y+k.h {
x, y = x-k.x, y-k.y // Offset
for _, rowKeys := range k.keys {
for _, key := range rowKeys {
if x >= key.x && x <= key.x+key.w && y >= key.y && y <= key.y+key.h {
return key
}
}
}
}
return nil
}
// KeyAt returns the key located at the specified position, or nil if no key is found.
func (k *Keyboard) KeyAt(x, y int) *Key {
return k.at(x, y)
}
func (k *Keyboard) handleToggleExtendedKey(inputKey ebiten.Key) bool {
if inputKey != KeyToggleExtended {
return false
}
k.showExtended = !k.showExtended
if k.showExtended {
k.keys = k.extendedKeys
} else {
k.keys = k.normalKeys
}
k.updateKeyRects()
k.backgroundDirty = true
return true
}
func (k *Keyboard) handleHideKey(inputKey ebiten.Key) bool {
if !k.allowUserHide {
return false
}
for _, key := range k.hideShortcuts {
if key == inputKey {
k.Hide()
return true
}
}
return false
}
// Hit handles a key press.
func (k *Keyboard) Hit(key *Key) {
input := key.LowerInput
if k.shift {
input = key.UpperInput
}
if input.Key == ebiten.KeyShift {
k.shift = !k.shift
if k.scheduleFrameFunc != nil {
k.scheduleFrameFunc()
}
return
} else if k.handleToggleExtendedKey(input.Key) || k.handleHideKey(input.Key) {
return
}
k.inputEvents = append(k.inputEvents, input)
}
// HandleMouse passes the specified mouse event to the on-screen keyboard.
func (k *Keyboard) HandleMouse(cursor image.Point, pressed bool, clicked bool) (handled bool, err error) {
if k.backgroundDirty {
k.drawBackground()
k.backgroundDirty = false
}
pressDuration := 50 * time.Millisecond
if k.wasPressed && !pressed && !clicked {
var key *Key
if cursor.X != 0 || cursor.Y != 0 {
key = k.at(cursor.X, cursor.Y)
} else {
PRESSKEY:
for _, rowKeys := range k.keys {
for _, rowKey := range rowKeys {
if rowKey.pressed {
key = rowKey
break PRESSKEY
}
}
}
}
for _, rowKeys := range k.keys {
for _, rowKey := range rowKeys {
if key != nil && rowKey == key {
continue
}
rowKey.pressed = false
}
}
if key != nil {
key.pressed = true
k.Hit(key)
go func() {
time.Sleep(pressDuration)
key.pressed = false
if k.scheduleFrameFunc != nil {
k.scheduleFrameFunc()
}
}()
}
k.wasPressed = false
} else if pressed {
key := k.at(cursor.X, cursor.Y)
if key != nil {
if !key.pressed {
input := key.LowerInput
if k.shift {
input = key.UpperInput
}
// Repeat backspace and delete operations.
if input.Key == ebiten.KeyBackspace || input.Key == ebiten.KeyDelete {
k.backspaceLast = time.Now().Add(k.backspaceDelay)
}
go func() {
t := time.NewTicker(k.backspaceRepeat)
for {
<-t.C
if !key.pressed {
t.Stop()
return
}
if (input.Key == ebiten.KeyBackspace || input.Key == ebiten.KeyDelete) && time.Since(k.backspaceLast) >= k.backspaceRepeat {
k.backspaceLast = time.Now()
k.inputEvents = append(k.inputEvents, &Input{Key: input.Key})
}
}
}()
}
key.pressed = true
k.wasPressed = true
for _, rowKeys := range k.keys {
for _, rowKey := range rowKeys {
if rowKey == key || !rowKey.pressed {
continue
}
rowKey.pressed = false
}
}
}
}
return true, nil
}
// Update handles user input. This function is called by Ebitengine.
func (k *Keyboard) Update() error {
if !k.visible {
return nil
}
if k.backgroundDirty {
k.drawBackground()
k.backgroundDirty = false
}
// Pass through physical keyboard input
if k.passPhysical {
// Read input characters
k.incomingBuffer = ebiten.AppendInputChars(k.incomingBuffer[:0])
if len(k.incomingBuffer) > 0 {
for _, r := range k.incomingBuffer {
k.inputEvents = append(k.inputEvents, &Input{Rune: r}) // Pass through
}
} else {
// Read keys
for _, key := range allKeys {
if inpututil.IsKeyJustPressed(key) {
if k.handleHideKey(key) {
// Hidden
return nil
}
k.inputEvents = append(k.inputEvents, &Input{Key: key}) // Pass through
}
}
}
}
// Handle mouse input
pressDuration := 50 * time.Millisecond
if inpututil.IsMouseButtonJustReleased(ebiten.MouseButtonLeft) {
x, y := ebiten.CursorPosition()
key := k.at(x, y)
if key != nil {
for _, rowKeys := range k.keys {
for _, rowKey := range rowKeys {
rowKey.pressed = false
}
}
key.pressed = true
k.Hit(key)
go func() {
time.Sleep(pressDuration)
key.pressed = false
if k.scheduleFrameFunc != nil {
k.scheduleFrameFunc()
}
}()
}
} else if ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) {
x, y := ebiten.CursorPosition()
key := k.at(x, y)
if key != nil {
if !key.pressed {
input := key.LowerInput
if k.shift {
input = key.UpperInput
}
// Repeat backspace and delete operations.
if input.Key == ebiten.KeyBackspace || input.Key == ebiten.KeyDelete {
k.backspaceLast = time.Now().Add(k.backspaceDelay)
}
go func() {
t := time.NewTicker(k.backspaceRepeat)
for {
<-t.C
if !key.pressed {
t.Stop()
return
}
if (input.Key == ebiten.KeyBackspace || input.Key == ebiten.KeyDelete) && time.Since(k.backspaceLast) >= k.backspaceRepeat {
k.backspaceLast = time.Now()
k.inputEvents = append(k.inputEvents, &Input{Key: input.Key})
}
}
}()
}
key.pressed = true
for _, rowKeys := range k.keys {
for _, rowKey := range rowKeys {
if rowKey == key || !rowKey.pressed {
continue
}
rowKey.pressed = false
}
}
}
}
// Handle touch input
if k.holdTouchID != -1 {
x, y := ebiten.TouchPosition(k.holdTouchID)
if x == 0 && y == 0 {
k.holdTouchID = -1
} else {
key := k.at(x, y)
if key != k.holdKey {
k.holdTouchID = -1
return nil
}
//k.Hold(key)
k.holdKey = key
}
}
if k.holdTouchID == -1 {
k.touchIDs = inpututil.AppendJustPressedTouchIDs(k.touchIDs[:0])
for _, id := range k.touchIDs {
x, y := ebiten.TouchPosition(id)
key := k.at(x, y)
if key != nil {
input := key.LowerInput
if k.shift {
input = key.UpperInput
}
if !key.pressed {
key.pressed = true
key.pressedTouchID = id
for _, rowKeys := range k.keys {
for _, rowKey := range rowKeys {
if rowKey != key && rowKey.pressed {
rowKey.pressed = false
}
}
}
k.Hit(key)
k.holdTouchID = id
k.holdKey = key
// Repeat backspace and delete operations.
if input.Key == ebiten.KeyBackspace || input.Key == ebiten.KeyDelete {
k.backspaceLast = time.Now().Add(k.backspaceDelay)
}
go func() {
var touchIDs []ebiten.TouchID
t := time.NewTicker(pressDuration)
for range t.C {
touchIDs = ebiten.AppendTouchIDs(touchIDs[:0])
var found bool
for _, touchID := range touchIDs {
if id == touchID {
found = true
break
}
}
if found {
tx, ty := ebiten.TouchPosition(id)
if tx != 0 || ty != 0 {
x, y = tx, ty
}
}
if !found {
key.pressed = false
if k.scheduleFrameFunc != nil {
k.scheduleFrameFunc()
}
t.Stop()
return
}
// Repeat backspace and delete operations.
if (input.Key == ebiten.KeyBackspace || input.Key == ebiten.KeyDelete) && time.Since(k.backspaceLast) >= k.backspaceRepeat {
k.backspaceLast = time.Now()
k.inputEvents = append(k.inputEvents, &Input{Key: input.Key})
}
}
}()
}
}
}
}
return nil
}
func (k *Keyboard) drawBackground() {
if k.w == 0 || k.h == 0 {
return
}
if !k.backgroundLower.Bounds().Eq(image.Rect(0, 0, k.w, k.h)) || !k.backgroundUpper.Bounds().Eq(image.Rect(0, 0, k.w, k.h)) || k.backgroundColor != k.lastBackgroundColor {
k.backgroundLower = ebiten.NewImage(k.w, k.h)
k.backgroundUpper = ebiten.NewImage(k.w, k.h)
k.lastBackgroundColor = k.backgroundColor
}
k.backgroundLower.Fill(k.backgroundColor)
k.backgroundUpper.Fill(k.backgroundColor)
halfLineHeight := k.lineHeight / 2
lightShade := color.RGBA{150, 150, 150, 255}
darkShade := color.RGBA{30, 30, 30, 255}
var keyImage *ebiten.Image
for i := 0; i < 2; i++ {
shift := i == 1
img := k.backgroundLower
if shift {
img = k.backgroundUpper
}
for _, rowKeys := range k.keys {
for _, key := range rowKeys {
r := image.Rect(key.x, key.y, key.x+key.w, key.y+key.h)
keyImage = img.SubImage(r).(*ebiten.Image)
// Draw key background
// TODO configurable
keyImage.Fill(color.RGBA{90, 90, 90, 255})
// Draw key label
label := key.LowerLabel
if shift {
label = key.UpperLabel
}
bounds := text.BoundString(k.labelFont, label)
x := (key.w - bounds.Dx()) / 2
if x < 0 {
x = 0
}
y := halfLineHeight + (key.h-halfLineHeight)/2
text.Draw(keyImage, label, k.labelFont, key.x+x, key.y+y, color.White)
// Draw border
keyImage.SubImage(image.Rect(key.x, key.y, key.x+key.w, key.y+1)).(*ebiten.Image).Fill(lightShade)
keyImage.SubImage(image.Rect(key.x, key.y, key.x+1, key.y+key.h)).(*ebiten.Image).Fill(lightShade)
keyImage.SubImage(image.Rect(key.x, key.y+key.h-1, key.x+key.w, key.y+key.h)).(*ebiten.Image).Fill(darkShade)
keyImage.SubImage(image.Rect(key.x+key.w-1, key.y, key.x+key.w, key.y+key.h)).(*ebiten.Image).Fill(darkShade)
}
}
}
}
// Draw draws the widget on the provided image. This function is called by Ebitengine.
func (k *Keyboard) Draw(target *ebiten.Image) {
if !k.visible {
return
}
if k.backgroundDirty {
k.drawBackground()
k.backgroundDirty = false
}
var background *ebiten.Image
if !k.shift {
background = k.backgroundLower
} else {
background = k.backgroundUpper
}
k.op.GeoM.Reset()
k.op.GeoM.Translate(float64(k.x), float64(k.y))
k.op.ColorM.Scale(1, 1, 1, k.alpha)
target.DrawImage(background, k.op)
k.op.ColorM.Reset()
// Draw pressed keys
for _, rowKeys := range k.keys {
for _, key := range rowKeys {
if !key.pressed {
continue
}
// TODO buffer to prevent issues with alpha channel
k.op.GeoM.Reset()
k.op.GeoM.Translate(float64(k.x+key.x), float64(k.y+key.y))
k.op.ColorM.Scale(0.75, 0.75, 0.75, k.alpha)
target.DrawImage(background.SubImage(image.Rect(key.x, key.y, key.x+key.w, key.y+key.h)).(*ebiten.Image), k.op)
k.op.ColorM.Reset()
// Draw shadow.
darkShade := color.RGBA{60, 60, 60, 255}
subImg := target.SubImage(image.Rect(k.x+key.x, k.y+key.y, k.x+key.x+key.w, k.y+key.y+1)).(*ebiten.Image)
subImg.Fill(darkShade)
subImg = target.SubImage(image.Rect(k.x+key.x, k.y+key.y, k.x+key.x+1, k.y+key.y+key.h)).(*ebiten.Image)
subImg.Fill(darkShade)
}
}
}
// SetAllowUserHide sets a flag that controls whether the widget may be hidden
// by the user.
func (k *Keyboard) SetAllowUserHide(allow bool) {
k.allowUserHide = allow
}
// SetPassThroughPhysicalInput sets a flag that controls whether physical
// keyboard input is passed through to the widget's input buffer. This is not
// enabled by default.
func (k *Keyboard) SetPassThroughPhysicalInput(pass bool) {
k.passPhysical = pass
}
// SetAlpha sets the transparency level of the widget on a scale of 0 to 1.0.
func (k *Keyboard) SetAlpha(alpha float64) {
k.alpha = alpha
}
// Show shows the widget.
func (k *Keyboard) Show() {
k.visible = true
}
// Visible returns whether the widget is currently shown.
func (k *Keyboard) Visible() bool {
return k.visible
}
// Hide hides the widget.
func (k *Keyboard) Hide() {
k.visible = false
if k.showExtended {
k.showExtended = false
k.keys = k.normalKeys
k.updateKeyRects()
k.backgroundDirty = true
}
}
// AppendInput appends user input that was received since the function was last called.
func (k *Keyboard) AppendInput(events []*Input) []*Input {
events = append(events, k.inputEvents...)
k.inputEvents = nil
return events
}
// SetScheduleFrameFunc sets the function called whenever the screen should be redrawn.
func (k *Keyboard) SetScheduleFrameFunc(f func()) {
k.scheduleFrameFunc = f
}
func keysEqual(a [][]*Key, b [][]*Key) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if len(a[i]) != len(b[i]) {
return false
}
for j := range b[i] {
if a[i][j] != b[i][j] {
return false
}
}
}
return true
}

85
kibodo/keyboard_test.go Normal file
View File

@ -0,0 +1,85 @@
package kibodo
import (
"runtime"
"testing"
"time"
"github.com/hajimehoshi/ebiten/v2"
)
// TODO test presses registered
func TestKeyboard_Draw(t *testing.T) {
k := newTestKeyboard()
// Warm caches
k.drawBackground()
}
func BenchmarkKeyboard_Draw(b *testing.B) {
k := newTestKeyboard()
// Warm caches
k.drawBackground()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
k.drawBackground()
}
}
func BenchmarkKeyboard_Press(b *testing.B) {
go func() {
time.Sleep(2 * time.Second)
k := newTestKeyboard()
// Warm caches
k.drawBackground()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
k.drawBackground()
k.keys[0][0].pressed = true
k.drawBackground()
k.keys[0][0].pressed = false
}
}()
runtime.LockOSThread()
err := ebiten.RunGame(NewDummyGame())
if err != nil {
b.Error(err)
}
}
func newTestKeyboard() *Keyboard {
k := NewKeyboard()
k.SetRect(0, 0, 300, 100)
return k
}
type DummyGame struct {
ready bool
}
func (d *DummyGame) Update() error {
return nil
}
func (d *DummyGame) Draw(screen *ebiten.Image) {
d.ready = true
}
func (d *DummyGame) Layout(outsideWidth, outsideHeight int) (screenWidth, screenHeight int) {
return outsideWidth, outsideHeight
}
func NewDummyGame() *DummyGame {
return &DummyGame{}
}

932
kibodo/keys.go Normal file
View File

@ -0,0 +1,932 @@
package kibodo
import (
"github.com/hajimehoshi/ebiten/v2"
)
// KeyToggleExtended is a sentinel key value. Any key with this value set will
// toggle the extended set of keys, if any defined via SetExtendedKeys.
var KeyToggleExtended = ebiten.Key(-10)
// KeysQWERTY is a standard QWERTY keyboard layout.
var KeysQWERTY = [][]*Key{
{
{
LowerLabel: "`",
UpperLabel: "~",
LowerInput: &Input{Rune: '`'},
UpperInput: &Input{Rune: '~'},
},
{
LowerLabel: "1",
UpperLabel: "!",
LowerInput: &Input{Rune: '1'},
UpperInput: &Input{Rune: '!'},
},
{
LowerLabel: "2",
UpperLabel: "@",
LowerInput: &Input{Rune: '2'},
UpperInput: &Input{Rune: '@'},
},
{
LowerLabel: "3",
UpperLabel: "#",
LowerInput: &Input{Rune: '3'},
UpperInput: &Input{Rune: '#'},
},
{
LowerLabel: "4",
UpperLabel: "$",
LowerInput: &Input{Rune: '4'},
UpperInput: &Input{Rune: '$'},
},
{
LowerLabel: "5",
UpperLabel: "%",
LowerInput: &Input{Rune: '5'},
UpperInput: &Input{Rune: '%'},
},
{
LowerLabel: "6",
UpperLabel: "^",
LowerInput: &Input{Rune: '6'},
UpperInput: &Input{Rune: '^'},
},
{
LowerLabel: "7",
UpperLabel: "&",
LowerInput: &Input{Rune: '7'},
UpperInput: &Input{Rune: '&'},
},
{
LowerLabel: "8",
UpperLabel: "*",
LowerInput: &Input{Rune: '8'},
UpperInput: &Input{Rune: '*'},
},
{
LowerLabel: "9",
UpperLabel: "(",
LowerInput: &Input{Rune: '9'},
UpperInput: &Input{Rune: '('},
},
{
LowerLabel: "0",
UpperLabel: ")",
LowerInput: &Input{Rune: '0'},
UpperInput: &Input{Rune: ')'},
},
{
LowerLabel: "-",
UpperLabel: "_",
LowerInput: &Input{Rune: '-'},
UpperInput: &Input{Rune: '_'},
},
{
LowerLabel: "=",
UpperLabel: "+",
LowerInput: &Input{Rune: '='},
UpperInput: &Input{Rune: '+'},
},
{
LowerLabel: "Backspace",
UpperLabel: "BACKSPACE",
LowerInput: &Input{Key: ebiten.KeyBackspace},
UpperInput: &Input{Key: ebiten.KeyBackspace},
},
},
{
{
LowerLabel: "q",
UpperLabel: "Q",
LowerInput: &Input{Rune: 'q'},
UpperInput: &Input{Rune: 'Q'},
},
{
LowerLabel: "w",
UpperLabel: "W",
LowerInput: &Input{Rune: 'w'},
UpperInput: &Input{Rune: 'W'},
},
{
LowerLabel: "e",
UpperLabel: "E",
LowerInput: &Input{Rune: 'e'},
UpperInput: &Input{Rune: 'E'},
},
{
LowerLabel: "r",
UpperLabel: "R",
LowerInput: &Input{Rune: 'r'},
UpperInput: &Input{Rune: 'R'},
},
{
LowerLabel: "t",
UpperLabel: "T",
LowerInput: &Input{Rune: 't'},
UpperInput: &Input{Rune: 'T'},
},
{
LowerLabel: "y",
UpperLabel: "Y",
LowerInput: &Input{Rune: 'y'},
UpperInput: &Input{Rune: 'Y'},
},
{
LowerLabel: "u",
UpperLabel: "U",
LowerInput: &Input{Rune: 'u'},
UpperInput: &Input{Rune: 'U'},
},
{
LowerLabel: "i",
UpperLabel: "I",
LowerInput: &Input{Rune: 'i'},
UpperInput: &Input{Rune: 'I'},
},
{
LowerLabel: "o",
UpperLabel: "O",
LowerInput: &Input{Rune: 'o'},
UpperInput: &Input{Rune: 'O'},
},
{
LowerLabel: "p",
UpperLabel: "P",
LowerInput: &Input{Rune: 'p'},
UpperInput: &Input{Rune: 'P'},
},
{
LowerLabel: "[",
UpperLabel: "{",
LowerInput: &Input{Rune: '['},
UpperInput: &Input{Rune: '{'},
},
{
LowerLabel: "]",
UpperLabel: "}",
LowerInput: &Input{Rune: ']'},
UpperInput: &Input{Rune: '}'},
},
{
LowerLabel: "\\",
UpperLabel: "|",
LowerInput: &Input{Rune: '\\'},
UpperInput: &Input{Rune: '|'},
},
},
{
{
LowerLabel: "a",
UpperLabel: "A",
LowerInput: &Input{Rune: 'a'},
UpperInput: &Input{Rune: 'A'},
},
{
LowerLabel: "s",
UpperLabel: "S",
LowerInput: &Input{Rune: 's'},
UpperInput: &Input{Rune: 'S'},
},
{
LowerLabel: "d",
UpperLabel: "D",
LowerInput: &Input{Rune: 'd'},
UpperInput: &Input{Rune: 'D'},
},
{
LowerLabel: "f",
UpperLabel: "F",
LowerInput: &Input{Rune: 'f'},
UpperInput: &Input{Rune: 'F'},
},
{
LowerLabel: "g",
UpperLabel: "G",
LowerInput: &Input{Rune: 'g'},
UpperInput: &Input{Rune: 'G'},
},
{
LowerLabel: "h",
UpperLabel: "H",
LowerInput: &Input{Rune: 'h'},
UpperInput: &Input{Rune: 'H'},
},
{
LowerLabel: "j",
UpperLabel: "J",
LowerInput: &Input{Rune: 'j'},
UpperInput: &Input{Rune: 'J'},
},
{
LowerLabel: "k",
UpperLabel: "K",
LowerInput: &Input{Rune: 'k'},
UpperInput: &Input{Rune: 'K'},
},
{
LowerLabel: "l",
UpperLabel: "L",
LowerInput: &Input{Rune: 'l'},
UpperInput: &Input{Rune: 'L'},
},
{
LowerLabel: ";",
UpperLabel: ":",
LowerInput: &Input{Rune: ';'},
UpperInput: &Input{Rune: ':'},
},
{
LowerLabel: `'`,
UpperLabel: `"`,
LowerInput: &Input{Rune: '\''},
UpperInput: &Input{Rune: '"'},
},
{
LowerLabel: "Enter",
UpperLabel: "ENTER",
LowerInput: &Input{Key: ebiten.KeyEnter},
UpperInput: &Input{Key: ebiten.KeyEnter},
},
},
{
{
LowerLabel: "Shift",
UpperLabel: "SHIFT",
LowerInput: &Input{Key: ebiten.KeyShift},
UpperInput: &Input{Key: ebiten.KeyShift},
},
{
LowerLabel: "z",
UpperLabel: "Z",
LowerInput: &Input{Rune: 'z'},
UpperInput: &Input{Rune: 'Z'},
},
{
LowerLabel: "x",
UpperLabel: "X",
LowerInput: &Input{Rune: 'x'},
UpperInput: &Input{Rune: 'X'},
},
{
LowerLabel: "c",
UpperLabel: "C",
LowerInput: &Input{Rune: 'c'},
UpperInput: &Input{Rune: 'C'},
},
{
LowerLabel: "v",
UpperLabel: "V",
LowerInput: &Input{Rune: 'v'},
UpperInput: &Input{Rune: 'V'},
},
{
LowerLabel: "b",
UpperLabel: "B",
LowerInput: &Input{Rune: 'b'},
UpperInput: &Input{Rune: 'B'},
},
{
LowerLabel: "n",
UpperLabel: "N",
LowerInput: &Input{Rune: 'n'},
UpperInput: &Input{Rune: 'N'},
},
{
LowerLabel: "m",
UpperLabel: "M",
LowerInput: &Input{Rune: 'm'},
UpperInput: &Input{Rune: 'M'},
},
{
LowerLabel: ",",