diff --git a/.gitignore b/.gitignore index 9464082a..142011b1 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,8 @@ go.json gop.json gop_autogen.go _gsc +_ui +_demo _test haiyang/ feijidazhan/ diff --git a/internal/ebitenui/doc.go b/internal/ebitenui/doc.go new file mode 100644 index 00000000..8913bd85 --- /dev/null +++ b/internal/ebitenui/doc.go @@ -0,0 +1,2 @@ +// Package ebitenui contains the main UI type that renders a complete user interface. +package ebitenui diff --git a/internal/ebitenui/event/deferred.go b/internal/ebitenui/event/deferred.go new file mode 100644 index 00000000..244f8ac6 --- /dev/null +++ b/internal/ebitenui/event/deferred.go @@ -0,0 +1,9 @@ +package event + +import internalevent "github.com/goplus/spx/internal/ebitenui/internal/event" + +// ExecuteDeferred processes the queue of deferred actions and executes them. This should only be called by UI. +// Additionally, it can be called in unit tests to process events programmatically. +func ExecuteDeferred() { + internalevent.ExecuteDeferred() +} diff --git a/internal/ebitenui/event/doc.go b/internal/ebitenui/event/doc.go new file mode 100644 index 00000000..7e16b150 --- /dev/null +++ b/internal/ebitenui/event/doc.go @@ -0,0 +1,2 @@ +// Package event contains types to deal with firing and handling events. +package event diff --git a/internal/ebitenui/event/event.go b/internal/ebitenui/event/event.go new file mode 100644 index 00000000..62520768 --- /dev/null +++ b/internal/ebitenui/event/event.go @@ -0,0 +1,104 @@ +package event + +import internalevent "github.com/goplus/spx/internal/ebitenui/internal/event" + +// Event encapsulates an arbitrary event that event handlers may be interested in. +type Event struct { + idCounter uint32 + handlers []handler +} + +// A HandlerFunc is a function that receives and handles an event. When firing an event using +// Event.Fire, arbitrary event arguments may be passed that are in turn passed on to the handler function. +type HandlerFunc func(args interface{}) + +// RemoveHandlerFunc is a function that removes a handler from an event. +type RemoveHandlerFunc func() + +type handler struct { + id uint32 + h HandlerFunc +} + +type deferredEvent struct { + event *Event + args interface{} +} + +type deferredAddHandler struct { + event *Event + handler handler +} + +// AddHandler registers event handler h with e. It returns a function to remove h from e if desired. +func (e *Event) AddHandler(h HandlerFunc) RemoveHandlerFunc { + e.idCounter++ + + id := e.idCounter + + internalevent.AddDeferred(&deferredAddHandler{ + event: e, + handler: handler{ + id: id, + h: h, + }, + }) + + return func() { + e.removeHandler(id) + } +} + +func (e *Event) removeHandler(id uint32) { + index := -1 + for i, h := range e.handlers { + if h.id == id { + index = i + break + } + } + + if index < 0 { + return + } + + e.handlers = append(e.handlers[:index], e.handlers[index+1:]...) +} + +// Fire fires an event to all registered handlers. Arbitrary event arguments may be passed +// which are in turn passed on to event handlers. +// +// Events are not fired directly, but are put into a deferred queue. This queue is then +// processed by the UI. +func (e *Event) Fire(args interface{}) { + internalevent.AddDeferred(&deferredEvent{ + event: e, + args: args, + }) +} + +func (e *Event) handle(args interface{}) { + for _, h := range e.handlers { + h.h(args) + } +} + +// Do implements DeferredAction. +func (e *deferredEvent) Do() { + e.event.handle(e.args) +} + +// Do implements DeferredAction. +func (a *deferredAddHandler) Do() { + a.event.handlers = append(a.event.handlers, a.handler) +} + +// AddEventHandlerOneShot registers event handler h with e. When e fires an event, h is removed from e immediately. +func AddEventHandlerOneShot(e *Event, h HandlerFunc) { + var r RemoveHandlerFunc + rh := func(args interface{}) { + r() + h(args) + } + r = e.AddHandler(rh) +} diff --git a/internal/ebitenui/examples/_textinput/main.go b/internal/ebitenui/examples/_textinput/main.go new file mode 100644 index 00000000..5927bdad --- /dev/null +++ b/internal/ebitenui/examples/_textinput/main.go @@ -0,0 +1,121 @@ +package main + +import ( + "image/color" + "log" + "strconv" + + _ "image/png" + + "github.com/goplus/spx/internal/ebitenui" + "github.com/goplus/spx/internal/ebitenui/widget" + "github.com/hajimehoshi/ebiten/v2" + "golang.org/x/image/font" + + xfont "github.com/goplus/spx/internal/gdi/font" +) + +type game struct { + ui *ebitenui.UI +} + +// Layout implements Game. +func (g *game) Layout(outsideWidth int, outsideHeight int) (int, int) { + return outsideWidth, outsideHeight +} + +// Update implements Game. +func (g *game) Update() error { + // update the UI + g.ui.Update() + return nil +} + +// Draw implements Ebiten's Draw method. +func (g *game) Draw(screen *ebiten.Image) { + // draw the UI onto the screen + g.ui.Draw(screen) +} + +func newPageContentContainer() *widget.Container { + return widget.NewContainer( + widget.ContainerOpts.WidgetOpts(widget.WidgetOpts.LayoutData(widget.AnchorLayoutData{ + StretchHorizontal: true, + })), + widget.ContainerOpts.Layout(widget.NewRowLayout( + widget.RowLayoutOpts.Direction(widget.DirectionVertical), + widget.RowLayoutOpts.Spacing(10), + ))) +} + +func hexToColor(h string) color.Color { + u, err := strconv.ParseUint(h, 16, 0) + if err != nil { + panic(err) + } + + return color.RGBA{ + R: uint8(u & 0xff0000 >> 16), + G: uint8(u & 0xff00 >> 8), + B: uint8(u & 0xff), + A: 255, + } +} + +const ( + textIdleColor = "dff4ff" + textDisabledColor = "5a7a91" + textInputCaretColor = "e7c34b" + textInputDisabledCaretColor = "766326" +) + +func main() { + // construct a new container that serves as the root of the UI hierarchy + rootContainer := newPageContentContainer() + + color := &widget.TextInputColor{ + Idle: hexToColor(textIdleColor), + Disabled: hexToColor(textDisabledColor), + Caret: hexToColor(textInputCaretColor), + DisabledCaret: hexToColor(textInputDisabledCaretColor), + } + const dpi = 72 + defaultFont := xfont.NewDefault(&xfont.Options{ + Size: 17, + DPI: dpi, + Hinting: font.HintingFull, + }) + t := widget.NewTextInput( + widget.TextInputOpts.WidgetOpts(widget.WidgetOpts.LayoutData(widget.RowLayoutData{ + Stretch: true, + })), + widget.TextInputOpts.Padding(widget.Insets{ + Left: 13, + Right: 13, + Top: 7, + Bottom: 7, + }), + widget.TextInputOpts.Color(color), + widget.TextInputOpts.Face(defaultFont), + widget.TextInputOpts.CaretOpts( + widget.CaretOpts.Size(defaultFont, 2), + ), + widget.TextInputOpts.Placeholder("Enter text here"), + ) + + rootContainer.AddChild(t) + + // construct the UI + ui := ebitenui.UI{ + Container: rootContainer, + } + game := game{ + ui: &ui, + } + + // run Ebiten main loop + err := ebiten.RunGame(&game) + if err != nil { + log.Println(err) + } +} diff --git a/internal/ebitenui/image/buffer.go b/internal/ebitenui/image/buffer.go new file mode 100644 index 00000000..d13f5c6c --- /dev/null +++ b/internal/ebitenui/image/buffer.go @@ -0,0 +1,67 @@ +package image + +import "github.com/hajimehoshi/ebiten/v2" + +// BufferedImage is a wrapper for an Ebiten Image that helps with caching the Image. +// As long as Width and Height stay the same, no new Image will be created. +type BufferedImage struct { + Width int + Height int + + image *ebiten.Image +} + +// MaskedRenderBuffer is a helper to draw images using a mask. +type MaskedRenderBuffer struct { + renderBuf *BufferedImage + maskedBuf *BufferedImage +} + +// DrawFunc is a function that draws something into buf. +type DrawFunc func(buf *ebiten.Image) + +// Image returns the internal Ebiten Image. If b.Width or b.Height have changed, a new Image +// will be created and returned, otherwise the cached Image will be returned. +func (b *BufferedImage) Image() *ebiten.Image { + w, h := -1, -1 + if b.image != nil { + w, h = b.image.Size() + } + + if b.image == nil || b.Width != w || b.Height != h { + b.image = ebiten.NewImage(b.Width, b.Height) + } + + return b.image +} + +// NewMaskedRenderBuffer returns a new MaskedRenderBuffer. +func NewMaskedRenderBuffer() *MaskedRenderBuffer { + return &MaskedRenderBuffer{ + renderBuf: &BufferedImage{}, + maskedBuf: &BufferedImage{}, + } +} + +// Draw calls d to draw onto screen, using the mask drawn by dm. The buffer images passed +// to d and dm are of the same size as screen. +func (m *MaskedRenderBuffer) Draw(screen *ebiten.Image, d DrawFunc, dm DrawFunc) { + w, h := screen.Size() + + m.renderBuf.Width, m.renderBuf.Height = w, h + renderBuf := m.renderBuf.Image() + renderBuf.Clear() + + m.maskedBuf.Width, m.maskedBuf.Height = w, h + maskedBuf := m.maskedBuf.Image() + maskedBuf.Clear() + + d(renderBuf) + dm(maskedBuf) + + maskedBuf.DrawImage(renderBuf, &ebiten.DrawImageOptions{ + CompositeMode: ebiten.CompositeModeSourceIn, + }) + + screen.DrawImage(maskedBuf, nil) +} diff --git a/internal/ebitenui/image/doc.go b/internal/ebitenui/image/doc.go new file mode 100644 index 00000000..9f3cac6b --- /dev/null +++ b/internal/ebitenui/image/doc.go @@ -0,0 +1,3 @@ +// Package image contains types to deal with nine-slice images, buffered (cached) images, as well as +// drawing using masks. +package image diff --git a/internal/ebitenui/image/nineslice.go b/internal/ebitenui/image/nineslice.go new file mode 100644 index 00000000..06b020c5 --- /dev/null +++ b/internal/ebitenui/image/nineslice.go @@ -0,0 +1,200 @@ +package image + +import ( + "image" + "image/color" + "sync" + + "github.com/hajimehoshi/ebiten/v2" +) + +// A NineSlice is an image that can be drawn with any width and height. It is basically a 3x3 grid of image tiles: +// The corner tiles are drawn as-is, while the center columns and rows of tiles will be stretched to fit the desired +// width and height. +type NineSlice struct { + image *ebiten.Image + widths [3]int + heights [3]int + transparent bool + + init sync.Once + tiles [9]*ebiten.Image +} + +// A DrawImageOptionsFunc is responsible for setting DrawImageOptions when drawing an image. +// This is usually used to translate the image. +type DrawImageOptionsFunc func(opts *ebiten.DrawImageOptions) + +var colorImages map[color.Color]*ebiten.Image = map[color.Color]*ebiten.Image{} + +var colorNineSlices map[color.Color]*NineSlice = map[color.Color]*NineSlice{} + +// NewNineSlice constructs a new NineSlice from i, having columns widths w and row heights h. +func NewNineSlice(i *ebiten.Image, w [3]int, h [3]int) *NineSlice { + return &NineSlice{ + image: i, + widths: w, + heights: h, + } +} + +// NewNineSliceSimple constructs a new NineSlice from image. borderWidthHeight specifies the width of the +// left and right column and the height of the top and bottom row. centerWidthHeight specifies the width +// of the center column and row. +func NewNineSliceSimple(image *ebiten.Image, borderWidthHeight int, centerWidthHeight int) *NineSlice { + return &NineSlice{ + image: image, + widths: [3]int{borderWidthHeight, centerWidthHeight, borderWidthHeight}, + heights: [3]int{borderWidthHeight, centerWidthHeight, borderWidthHeight}, + } +} + +// NewNineSliceColor constructs a new NineSlice that when drawn fills with color c. +func NewNineSliceColor(c color.Color) *NineSlice { + if n, ok := colorNineSlices[c]; ok { + return n + } + + var n *NineSlice + _, _, _, a := c.RGBA() + if a == 0 { + n = &NineSlice{ + transparent: true, + } + } else { + n = &NineSlice{ + image: NewImageColor(c), + widths: [3]int{0, 1, 0}, + heights: [3]int{0, 1, 0}, + } + } + colorNineSlices[c] = n + return n +} + +// NewImageColor constructs a new Image that when drawn fills with color c. +func NewImageColor(c color.Color) *ebiten.Image { + if i, ok := colorImages[c]; ok { + return i + } + + i := ebiten.NewImage(1, 1) + i.Fill(c) + colorImages[c] = i + return i +} + +// Draw draws n onto screen, with the size specified by width and height. If optsFunc is not nil, it is used to set +// DrawImageOptions for each tile drawn. +func (n *NineSlice) Draw(screen *ebiten.Image, width int, height int, optsFunc DrawImageOptionsFunc) { + if n.transparent { + return + } + + n.drawTiles(screen, width, height, optsFunc) +} + +func (n *NineSlice) drawTiles(screen *ebiten.Image, width int, height int, optsFunc DrawImageOptionsFunc) { + n.init.Do(n.createTiles) + + sy := 0 + ty := 0 + for r, sh := range n.heights { + sx := 0 + tx := 0 + + var th int + if r == 1 { + th = height - n.heights[0] - n.heights[2] + } else { + th = sh + } + + for c, sw := range n.widths { + var tw int + if c == 1 { + tw = width - n.widths[0] - n.widths[2] + } else { + tw = sw + } + + n.drawTile(screen, n.tiles[r*3+c], tx, ty, sw, sh, tw, th, optsFunc) + + sx += sw + tx += tw + } + + sy += sh + ty += th + } +} + +func (n *NineSlice) drawTile(screen *ebiten.Image, tile *ebiten.Image, tx int, ty int, sw int, sh int, tw int, th int, optsFunc DrawImageOptionsFunc) { + if sw <= 0 || sh <= 0 || tw <= 0 || th <= 0 { + return + } + + opts := ebiten.DrawImageOptions{ + Filter: ebiten.FilterNearest, + } + + if tw != sw || th != sh { + opts.GeoM.Scale(float64(tw)/float64(sw), float64(th)/float64(sh)) + } + + opts.GeoM.Translate(float64(tx), float64(ty)) + + if optsFunc != nil { + optsFunc(&opts) + } + + screen.DrawImage(tile, &opts) +} + +func (n *NineSlice) createTiles() { + defer func() { + n.image = nil + }() + + n.tiles = [9]*ebiten.Image{} + + if n.centerOnly() { + n.tiles[1*3+1] = n.image + return + } + + min := n.image.Bounds().Min + + sy := min.Y + for r, sh := range n.heights { + sx := min.X + for c, sw := range n.widths { + if sh > 0 && sw > 0 { + rect := image.Rect(0, 0, sw, sh) + rect = rect.Add(image.Point{sx, sy}) + n.tiles[r*3+c] = n.image.SubImage(rect).(*ebiten.Image) + } + sx += sw + } + sy += sh + } +} + +func (n *NineSlice) centerOnly() bool { + if n.widths[0] > 0 || n.widths[2] > 0 || n.heights[0] > 0 || n.heights[2] > 0 { + return false + } + + w, h := n.image.Size() + return n.widths[1] == w && n.heights[1] == h +} + +// MinSize returns the minimum width and height to draw n correctly. If n is drawn with a smaller size, +// the corner or edge tiles will overlap. +func (n *NineSlice) MinSize() (int, int) { + if n.transparent { + return 0, 0 + } + + return n.widths[0] + n.widths[2], n.heights[0] + n.heights[2] +} diff --git a/internal/ebitenui/input/doc.go b/internal/ebitenui/input/doc.go new file mode 100644 index 00000000..98198c53 --- /dev/null +++ b/internal/ebitenui/input/doc.go @@ -0,0 +1,6 @@ +// Package input deals with user input such as mouse button clicks, scroll wheel movement etc. +// It also provides access to the input layer stack to handle staggered input. +// +// Widget implementations should always use this package to handle user input rather than using +// Ebiten functions directly. +package input diff --git a/internal/ebitenui/input/input.go b/internal/ebitenui/input/input.go new file mode 100644 index 00000000..c1337ad0 --- /dev/null +++ b/internal/ebitenui/input/input.go @@ -0,0 +1,99 @@ +package input + +import ( + internalinput "github.com/goplus/spx/internal/ebitenui/internal/input" + "github.com/hajimehoshi/ebiten/v2" +) + +// MouseButtonPressed returns whether mouse button b is currently pressed. +func MouseButtonPressed(b ebiten.MouseButton) bool { + switch b { + case ebiten.MouseButtonLeft: + return internalinput.LeftMouseButtonPressed + case ebiten.MouseButtonMiddle: + return internalinput.MiddleMouseButtonPressed + case ebiten.MouseButtonRight: + return internalinput.RightMouseButtonPressed + default: + return false + } +} + +// MouseButtonJustPressed returns whether mouse button b has just been pressed. +// It only returns true during the first frame that the button is pressed. +func MouseButtonJustPressed(b ebiten.MouseButton) bool { + switch b { + case ebiten.MouseButtonLeft: + return internalinput.LeftMouseButtonJustPressed + case ebiten.MouseButtonMiddle: + return internalinput.MiddleMouseButtonJustPressed + case ebiten.MouseButtonRight: + return internalinput.RightMouseButtonJustPressed + default: + return false + } +} + +// MouseButtonPressedLayer returns whether mouse button b is currently pressed if input layer l is +// eligible to handle it. +func MouseButtonPressedLayer(b ebiten.MouseButton, l *Layer) bool { + if !MouseButtonPressed(b) { + return false + } + + x, y := CursorPosition() + return l.ActiveFor(x, y, LayerEventTypeMouseButton) +} + +// MouseButtonJustPressedLayer returns whether mouse button b has just been pressed if input layer l +// is eligible to handle it. It only returns true during the first frame that the button is pressed. +func MouseButtonJustPressedLayer(b ebiten.MouseButton, l *Layer) bool { + if !MouseButtonJustPressed(b) { + return false + } + + x, y := CursorPosition() + return l.ActiveFor(x, y, LayerEventTypeMouseButton) +} + +// CursorPosition returns the current cursor position. +func CursorPosition() (int, int) { + return internalinput.CursorX, internalinput.CursorY +} + +// Wheel returns current mouse wheel movement. +func Wheel() (float64, float64) { + return internalinput.WheelX, internalinput.WheelY +} + +// WheelLayer returns current mouse wheel movement if input layer l is eligible to handle it. +// If l is not eligible, it returns 0, 0. +func WheelLayer(l *Layer) (float64, float64) { + x, y := Wheel() + if x == 0 && y == 0 { + return 0, 0 + } + + cx, cy := CursorPosition() + if !l.ActiveFor(cx, cy, LayerEventTypeWheel) { + return 0, 0 + } + + return x, y +} + +// InputChars returns user keyboard input. +func InputChars() []rune { //nolint:golint + return internalinput.InputChars +} + +// KeyPressed returns whether key k is currently pressed. +func KeyPressed(k ebiten.Key) bool { + p, ok := internalinput.KeyPressed[k] + return ok && p +} + +// AnyKeyPressed returns whether any key is currently pressed. +func AnyKeyPressed() bool { + return internalinput.AnyKeyPressed +} diff --git a/internal/ebitenui/input/layer.go b/internal/ebitenui/input/layer.go new file mode 100644 index 00000000..850f25c9 --- /dev/null +++ b/internal/ebitenui/input/layer.go @@ -0,0 +1,171 @@ +package input + +import ( + "image" +) + +// Layerer may be implemented by widgets that need to set up input layers by calling AddLayer. +type Layerer interface { + // SetupInputLayer sets up input layers. def may be called to defer additional input layer setup. + SetupInputLayer(def DeferredSetupInputLayerFunc) +} + +// SetupInputLayerFunc is a function that sets up input layers by calling AddLayer. +// def may be called to defer additional input layer setup. +type SetupInputLayerFunc func(def DeferredSetupInputLayerFunc) + +// DeferredSetupInputLayerFunc is a function that stores s for deferred execution. +type DeferredSetupInputLayerFunc func(s SetupInputLayerFunc) + +// A Layer is an input layer that can be used to block user input from lower layers of the user interface. +// For example, if two clickable areas overlap each other, clicking on the overlapping part should only result +// in a click event sent to the upper area instead of both. Input layers can be used to achieve this. +// +// Input layers are stacked: Lower layers may be eligible to handle an event if upper layers are not, or if +// upper layers specify to pass events on to lower layers regardless. +// +// Input layers may specify a screen Rectangle as their area of interest, or they may specify to cover the +// full screen. +type Layer struct { + // DebugLabel is a label used in debugging to distinguish input layers. It is not used in any other way. + DebugLabel string + + // EventTypes is a bit mask that specifies the types of events the input layer is eligible for. + EventTypes LayerEventType + + // BlockLower specifies if events will be passed on to lower input layers even if the current layer + // is eligible to handle them. + BlockLower bool + + // FullScreen specifies if the input layer covers the full screen. + FullScreen bool + + // RectFunc is a function that returns the input layer's screen area of interest. This function is only + // called if FullScreen is false. + RectFunc LayerRectFunc + + invalid bool +} + +// LayerRectFunc is a function that returns a Layer's screen area of interest. +type LayerRectFunc func() image.Rectangle + +// LayerEventType is a type of input event, such as mouse button press or release, wheel click, and so on. +type LayerEventType uint16 + +const ( + // LayerEventTypeAny is used for ActiveFor to indicate no special event types. + LayerEventTypeAny = LayerEventType(0) +) + +const ( + // LayerEventTypeMouseButton indicates an interest in mouse button events. + LayerEventTypeMouseButton = LayerEventType(1 << iota) + + // LayerEventTypeWheel indicates an interest in mouse wheel events. + LayerEventTypeWheel + + // LayerEventTypeAll indicates an interest in all event types. + LayerEventTypeAll = LayerEventType(^uint16(0)) +) + +// DefaultLayer is the bottom-most input layer. It is a full screen layer that is eligible for all event types. +var DefaultLayer = Layer{ + DebugLabel: "default", + EventTypes: LayerEventTypeAll, + BlockLower: true, + FullScreen: true, +} + +var layers []*Layer + +var deferredSetupInputLayers []SetupInputLayerFunc + +// AddLayer adds l at the top of the layer stack. +// +// Layers are only valid for the duration of a frame. Layers are removed automatically for the next frame. +func AddLayer(l *Layer) { + if !l.Valid() { + panic("invalid layer") + } + + if l.EventTypes == LayerEventTypeAny { + panic("LayerEventTypeAny is invalid for an input layer, perhaps you meant to use LayerEventTypeAll instead") + } + + layers = append(layers, l) +} + +// Valid returns whether l is still valid, that is, it has not been added to the layer stack in previous frames. +func (l *Layer) Valid() bool { + return !l.invalid +} + +// ActiveFor returns whether l is eligible for an event of type eventType, according to l.EventTypes. It returns +// false if l is not a fullscreen layer and does not contain the position x,y. +func (l *Layer) ActiveFor(x int, y int, eventType LayerEventType) bool { + if !l.Valid() { + return false + } + + for i := len(layers) - 1; i >= 0; i-- { + layer := layers[i] + + if !layer.contains(x, y) { + continue + } + + if eventType != LayerEventTypeAny && layer.EventTypes&eventType != eventType { + continue + } + + if layer != l { + if layer.BlockLower { + return false + } + continue + } + + return true + } + + return l == &DefaultLayer +} + +func (l *Layer) contains(x int, y int) bool { + if l.FullScreen { + return true + } + return image.Point{x, y}.In(l.RectFunc()) +} + +// SetupInputLayersWithDeferred calls ls to set up input layers. This function is called by the UI. +func SetupInputLayersWithDeferred(ls []Layerer) { + for _, layer := range layers { + layer.invalid = true + } + layers = layers[:0] + + for _, l := range ls { + appendToDeferredSetupInputLayerQueue(l.SetupInputLayer) + } + + setupDeferredInputLayers() +} + +func setupDeferredInputLayers() { + defer func(d []SetupInputLayerFunc) { + deferredSetupInputLayers = d[:0] + }(deferredSetupInputLayers) + + for len(deferredSetupInputLayers) > 0 { + s := deferredSetupInputLayers[0] + deferredSetupInputLayers = deferredSetupInputLayers[1:] + + s(appendToDeferredSetupInputLayerQueue) + } +} + +func appendToDeferredSetupInputLayerQueue(s SetupInputLayerFunc) { + deferredSetupInputLayers = append(deferredSetupInputLayers, s) +} diff --git a/internal/ebitenui/internal/event/deferred.go b/internal/ebitenui/internal/event/deferred.go new file mode 100644 index 00000000..7db831eb --- /dev/null +++ b/internal/ebitenui/internal/event/deferred.go @@ -0,0 +1,28 @@ +package event + +// A DeferredAction is an action that is executed at a later time. +type DeferredAction interface { + // Do executes the action. + Do() +} + +var deferredActions []DeferredAction + +// AddDeferred adds d to the queue of deferred actions. +func AddDeferred(d DeferredAction) { + deferredActions = append(deferredActions, d) +} + +// ExecuteDeferred processes the queue of deferred actions and executes them. +func ExecuteDeferred() { + defer func(d []DeferredAction) { + deferredActions = d[:0] + }(deferredActions) + + for len(deferredActions) > 0 { + a := deferredActions[0] + deferredActions = deferredActions[1:] + + a.Do() + } +} diff --git a/internal/ebitenui/internal/input/input.go b/internal/ebitenui/internal/input/input.go new file mode 100644 index 00000000..ee07aa52 --- /dev/null +++ b/internal/ebitenui/internal/input/input.go @@ -0,0 +1,68 @@ +package input + +import ( + "github.com/hajimehoshi/ebiten/v2" +) + +var ( + LeftMouseButtonPressed bool + MiddleMouseButtonPressed bool + RightMouseButtonPressed bool + CursorX int + CursorY int + WheelX float64 + WheelY float64 + + LeftMouseButtonJustPressed bool + MiddleMouseButtonJustPressed bool + RightMouseButtonJustPressed bool + + LastLeftMouseButtonPressed bool + LastMiddleMouseButtonPressed bool + LastRightMouseButtonPressed bool + + InputChars []rune + KeyPressed = map[ebiten.Key]bool{} + AnyKeyPressed bool +) + +// Update updates the input system. This is called by the UI. +func Update() { + LeftMouseButtonPressed = ebiten.IsMouseButtonPressed(ebiten.MouseButtonLeft) + MiddleMouseButtonPressed = ebiten.IsMouseButtonPressed(ebiten.MouseButtonMiddle) + RightMouseButtonPressed = ebiten.IsMouseButtonPressed(ebiten.MouseButtonRight) + CursorX, CursorY = ebiten.CursorPosition() + + wx, wy := ebiten.Wheel() + WheelX += wx + WheelY += wy + + InputChars = ebiten.AppendInputChars(InputChars) + + AnyKeyPressed = false + for k := ebiten.Key(0); k <= ebiten.KeyMax; k++ { + p := ebiten.IsKeyPressed(k) + KeyPressed[k] = p + + if p { + AnyKeyPressed = true + } + } +} + +// Draw updates the input system. This is called by the UI. +func Draw() { + LeftMouseButtonJustPressed = LeftMouseButtonPressed && LeftMouseButtonPressed != LastLeftMouseButtonPressed + MiddleMouseButtonJustPressed = MiddleMouseButtonPressed && MiddleMouseButtonPressed != LastMiddleMouseButtonPressed + RightMouseButtonJustPressed = RightMouseButtonPressed && RightMouseButtonPressed != LastRightMouseButtonPressed + + LastLeftMouseButtonPressed = LeftMouseButtonPressed + LastMiddleMouseButtonPressed = MiddleMouseButtonPressed + LastRightMouseButtonPressed = RightMouseButtonPressed +} + +// AfterDraw updates the input system after the Ebiten Draw function has been called. This is called by the UI. +func AfterDraw() { + InputChars = InputChars[:0] + WheelX, WheelY = 0, 0 +} diff --git a/internal/ebitenui/ui.go b/internal/ebitenui/ui.go new file mode 100644 index 00000000..247bc963 --- /dev/null +++ b/internal/ebitenui/ui.go @@ -0,0 +1,102 @@ +package ebitenui + +import ( + "image" + + "github.com/goplus/spx/internal/ebitenui/event" + "github.com/goplus/spx/internal/ebitenui/input" + internalinput "github.com/goplus/spx/internal/ebitenui/internal/input" + "github.com/goplus/spx/internal/ebitenui/widget" + + "github.com/hajimehoshi/ebiten/v2" +) + +// UI encapsulates a complete user interface that can be rendered onto the screen. +// There should only be exactly one UI per application. +type UI struct { + // Container is the root container of the UI hierarchy. + Container *widget.Container + + lastRect image.Rectangle + focusedWidget widget.HasWidget + inputLayerers []input.Layerer + renderers []widget.Renderer +} + +// RemoveWindowFunc is a function to remove a Window from rendering. +type RemoveWindowFunc func() + +// Update updates u. This method should be called in the Ebiten Update function. +func (u *UI) Update() { + internalinput.Update() +} + +// Draw renders u onto screen. This function should be called in the Ebiten Draw function. +// +// If screen's size changes from one frame to the next, u.Container.RequestRelayout is called. +func (u *UI) Draw(screen *ebiten.Image) { + event.ExecuteDeferred() + + internalinput.Draw() + defer internalinput.AfterDraw() + + w, h := screen.Size() + rect := image.Rect(0, 0, w, h) + + defer func() { + u.lastRect = rect + }() + + if rect != u.lastRect { + u.Container.RequestRelayout() + } + + u.handleFocus() + u.setupInputLayers() + u.Container.SetLocation(rect) + u.render(screen) +} + +func (u *UI) handleFocus() { + if input.MouseButtonJustPressed(ebiten.MouseButtonLeft) { + if u.focusedWidget != nil { + u.focusedWidget.(widget.Focuser).Focus(false) + u.focusedWidget = nil + } + + x, y := input.CursorPosition() + w := u.Container.WidgetAt(x, y) + if w != nil { + if f, ok := w.(widget.Focuser); ok { + f.Focus(true) + u.focusedWidget = w + } + } + } +} + +func (u *UI) setupInputLayers() { + num := 1 // u.Container + if cap(u.inputLayerers) < num { + u.inputLayerers = make([]input.Layerer, num) + } + + u.inputLayerers = u.inputLayerers[:0] + u.inputLayerers = append(u.inputLayerers, u.Container) + + // TODO: SetupInputLayersWithDeferred should reside in "internal" subpackage + input.SetupInputLayersWithDeferred(u.inputLayerers) +} + +func (u *UI) render(screen *ebiten.Image) { + num := 1 // u.Container + if cap(u.renderers) < num { + u.renderers = make([]widget.Renderer, num) + } + + u.renderers = u.renderers[:0] + u.renderers = append(u.renderers, u.Container) + + // TODO: RenderWithDeferred should reside in "internal" subpackage + widget.RenderWithDeferred(screen, u.renderers) +} diff --git a/internal/ebitenui/widget/anchorlayout.go b/internal/ebitenui/widget/anchorlayout.go new file mode 100644 index 00000000..c7808c63 --- /dev/null +++ b/internal/ebitenui/widget/anchorlayout.go @@ -0,0 +1,133 @@ +package widget + +import "image" + +// AnchorLayout layouts a single widget anchored to either a corner or edge of a rectangle, +// optionally stretching it in one or both directions. +// +// AnchorLayout will only layout the first widget in a container and ignore all other widgets. +// +// Widget.LayoutData of widgets being layouted by AnchorLayout need to be of type AnchorLayoutData. +type AnchorLayout struct { + padding Insets +} + +// AnchorLayoutOpt is a function that configures a. +type AnchorLayoutOpt func(a *AnchorLayout) + +type AnchorLayoutOptions struct { +} + +// AnchorLayoutPosition is the type used to specify an anchoring position. +type AnchorLayoutPosition int + +// AnchorLayoutData specifies layout settings for a widget. +type AnchorLayoutData struct { + // HorizontalPosition specifies the horizontal anchoring position. + HorizontalPosition AnchorLayoutPosition + + // VerticalPosition specifies the vertical anchoring position. + VerticalPosition AnchorLayoutPosition + + // StretchHorizontal specifies whether to stretch in the horizontal direction. + StretchHorizontal bool + + // StretchVertical specifies whether to stretch in the vertical direction. + StretchVertical bool +} + +const ( + // AnchorLayoutPositionStart is the anchoring position for "left" (in the horizontal direction) or "top" (in the vertical direction.) + AnchorLayoutPositionStart = AnchorLayoutPosition(iota) + + // AnchorLayoutPositionCenter is the center anchoring position. + AnchorLayoutPositionCenter + + // AnchorLayoutPositionEnd is the anchoring position for "right" (in the horizontal direction) or "bottom" (in the vertical direction.) + AnchorLayoutPositionEnd +) + +// AnchorLayoutOpts contains functions that configure an AnchorLayout. +var AnchorLayoutOpts AnchorLayoutOptions + +// NewAnchorLayout constructs a new AnchorLayout, configured by opts. +func NewAnchorLayout(opts ...AnchorLayoutOpt) *AnchorLayout { + a := &AnchorLayout{} + + for _, o := range opts { + o(a) + } + + return a +} + +// Padding configures an anchor layout to use padding i. +func (o AnchorLayoutOptions) Padding(i Insets) AnchorLayoutOpt { + return func(a *AnchorLayout) { + a.padding = i + } +} + +// PreferredSize implements Layouter. +func (a *AnchorLayout) PreferredSize(widgets []PreferredSizeLocateableWidget) (int, int) { + px, py := a.padding.Dx(), a.padding.Dy() + + if len(widgets) == 0 { + return px, py + } + + w, h := widgets[0].PreferredSize() + return w + px, h + py +} + +// Layout implements Layouter. +func (a *AnchorLayout) Layout(widgets []PreferredSizeLocateableWidget, rect image.Rectangle) { + if len(widgets) == 0 { + return + } + + widget := widgets[0] + ww, wh := widget.PreferredSize() + rect = a.padding.Apply(rect) + wx := 0 + wy := 0 + + if ald, ok := widget.GetWidget().LayoutData.(AnchorLayoutData); ok { + wx, wy, ww, wh = a.applyLayoutData(ald, wx, wy, ww, wh, rect) + } + + r := image.Rect(0, 0, ww, wh) + r = r.Add(image.Point{wx, wy}) + r = r.Add(rect.Min) + + widget.SetLocation(r) +} + +func (a *AnchorLayout) applyLayoutData(ld AnchorLayoutData, wx int, wy int, ww int, wh int, rect image.Rectangle) (int, int, int, int) { + if ld.StretchHorizontal { + ww = rect.Dx() + } + + if ld.StretchVertical { + wh = rect.Dy() + } + + hPos := ld.HorizontalPosition + vPos := ld.VerticalPosition + + switch hPos { + case AnchorLayoutPositionCenter: + wx = (rect.Dx() - ww) / 2 + case AnchorLayoutPositionEnd: + wx = rect.Dx() - ww + } + + switch vPos { + case AnchorLayoutPositionCenter: + wy = (rect.Dy() - wh) / 2 + case AnchorLayoutPositionEnd: + wy = rect.Dy() - wh + } + + return wx, wy, ww, wh +} diff --git a/internal/ebitenui/widget/caret.go b/internal/ebitenui/widget/caret.go new file mode 100644 index 00000000..ac250cbd --- /dev/null +++ b/internal/ebitenui/widget/caret.go @@ -0,0 +1,139 @@ +package widget + +import ( + img "image" + "image/color" + "math" + "sync/atomic" + "time" + + "github.com/goplus/spx/internal/ebitenui/image" + "github.com/hajimehoshi/ebiten/v2" + "golang.org/x/image/font" +) + +type Caret struct { + Width int + Color color.Color + + face font.Face + blinkInterval time.Duration + + init *MultiOnce + widget *Widget + image *image.NineSlice + height int + state caretBlinkState + visible bool +} + +type CaretOpt func(c *Caret) + +type CaretOptions struct { +} + +var CaretOpts CaretOptions + +type caretBlinkState func() caretBlinkState + +func NewCaret(opts ...CaretOpt) *Caret { + c := &Caret{ + blinkInterval: 450 * time.Millisecond, + + init: &MultiOnce{}, + } + c.resetBlinking() + + c.init.Append(c.createWidget) + + for _, o := range opts { + o(c) + } + + return c +} + +func (o CaretOptions) Color(c color.Color) CaretOpt { + return func(ca *Caret) { + ca.Color = c + } +} + +func (o CaretOptions) Size(face font.Face, width int) CaretOpt { + return func(c *Caret) { + c.face = face + c.Width = width + } +} + +func (c *Caret) GetWidget() *Widget { + c.init.Do() + return c.widget +} + +func (c *Caret) SetLocation(rect img.Rectangle) { + c.init.Do() + c.widget.Rect = rect +} + +func (c *Caret) PreferredSize() (int, int) { + c.init.Do() + return c.Width, c.height +} + +func (c *Caret) Render(screen *ebiten.Image, def DeferredRenderFunc) { + c.init.Do() + + c.state = c.state() + + c.widget.Render(screen, def) + + if !c.visible { + return + } + + c.image = image.NewNineSliceColor(c.Color) + + c.image.Draw(screen, c.Width, c.height, func(opts *ebiten.DrawImageOptions) { + p := c.widget.Rect.Min + opts.GeoM.Translate(float64(p.X), float64(p.Y)) + }) +} + +func (c *Caret) ResetBlinking() { + c.init.Do() + c.resetBlinking() +} + +func (c *Caret) resetBlinking() { + c.state = c.blinkState(true, nil, nil) +} + +func (c *Caret) blinkState(visible bool, timer *time.Timer, expired *atomic.Value) caretBlinkState { + return func() caretBlinkState { + c.visible = visible + + if timer != nil && expired.Load().(bool) { + return c.blinkState(!visible, nil, nil) + } + + if timer == nil { + expired = &atomic.Value{} + expired.Store(false) + + timer = time.AfterFunc(c.blinkInterval, func() { + expired.Store(true) + }) + } + + return c.blinkState(visible, timer, expired) + } +} + +func (c *Caret) createWidget() { + c.widget = NewWidget() + + m := c.face.Metrics() + c.height = int(math.Round(fixedInt26_6ToFloat64(m.Ascent + m.Descent))) + c.face = nil +} diff --git a/internal/ebitenui/widget/container.go b/internal/ebitenui/widget/container.go new file mode 100644 index 00000000..3285594d --- /dev/null +++ b/internal/ebitenui/widget/container.go @@ -0,0 +1,224 @@ +package widget + +import ( + img "image" + + "github.com/goplus/spx/internal/ebitenui/image" + "github.com/goplus/spx/internal/ebitenui/input" + + "github.com/hajimehoshi/ebiten/v2" +) + +type Container struct { + BackgroundImage *image.NineSlice + AutoDisableChildren bool + + widgetOpts []WidgetOpt + layout Layouter + layoutDirty bool + + init *MultiOnce + widget *Widget + children []PreferredSizeLocateableWidget +} + +type ContainerOpt func(c *Container) + +type RemoveChildFunc func() + +type ContainerOptions struct { +} + +var ContainerOpts ContainerOptions + +type PreferredSizeLocateableWidget interface { + HasWidget + PreferredSizer + Locateable +} + +func NewContainer(opts ...ContainerOpt) *Container { + c := &Container{ + init: &MultiOnce{}, + } + + c.init.Append(c.createWidget) + + for _, o := range opts { + o(c) + } + + return c +} + +func (o ContainerOptions) WidgetOpts(opts ...WidgetOpt) ContainerOpt { + return func(c *Container) { + c.widgetOpts = append(c.widgetOpts, opts...) + } +} + +func (o ContainerOptions) BackgroundImage(i *image.NineSlice) ContainerOpt { + return func(c *Container) { + c.BackgroundImage = i + } +} + +func (o ContainerOptions) AutoDisableChildren() ContainerOpt { + return func(c *Container) { + c.AutoDisableChildren = true + } +} + +func (o ContainerOptions) Layout(layout Layouter) ContainerOpt { + return func(c *Container) { + c.layout = layout + } +} + +func (c *Container) AddChild(child PreferredSizeLocateableWidget) RemoveChildFunc { + c.init.Do() + + if child == nil { + panic("cannot add nil child") + } + + c.children = append(c.children, child) + + child.GetWidget().parent = c.widget + + c.RequestRelayout() + + return func() { + c.removeChild(child) + } +} + +func (c *Container) removeChild(child PreferredSizeLocateableWidget) { + index := -1 + for i, ch := range c.children { + if ch == child { + index = i + break + } + } + + if index < 0 { + return + } + + c.children = append(c.children[:index], c.children[index+1:]...) + + child.GetWidget().parent = nil + + c.RequestRelayout() +} + +func (c *Container) RequestRelayout() { + c.init.Do() + + c.layoutDirty = true + + for _, ch := range c.children { + if r, ok := ch.(Relayoutable); ok { + r.RequestRelayout() + } + } +} + +func (c *Container) GetWidget() *Widget { + c.init.Do() + return c.widget +} + +func (c *Container) PreferredSize() (int, int) { + c.init.Do() + + if c.layout == nil { + return 50, 50 + } + + return c.layout.PreferredSize(c.children) +} + +func (c *Container) SetLocation(rect img.Rectangle) { + c.init.Do() + c.widget.Rect = rect +} + +func (c *Container) Render(screen *ebiten.Image, def DeferredRenderFunc) { + c.init.Do() + + if c.AutoDisableChildren { + for _, ch := range c.children { + ch.GetWidget().Disabled = c.widget.Disabled + } + } + + c.widget.Render(screen, def) + + c.doLayout() + + c.draw(screen) + + for _, ch := range c.children { + if cr, ok := ch.(Renderer); ok { + cr.Render(screen, def) + } + } +} + +func (c *Container) doLayout() { + if c.layout != nil && c.layoutDirty { + c.layout.Layout(c.children, c.widget.Rect) + c.layoutDirty = false + } +} + +func (c *Container) SetupInputLayer(def input.DeferredSetupInputLayerFunc) { + c.init.Do() + + for _, ch := range c.children { + if il, ok := ch.(input.Layerer); ok { + il.SetupInputLayer(def) + } + } +} + +func (c *Container) draw(screen *ebiten.Image) { + if c.BackgroundImage != nil { + c.BackgroundImage.Draw(screen, c.widget.Rect.Dx(), c.widget.Rect.Dy(), c.widget.drawImageOptions) + } +} + +func (c *Container) createWidget() { + c.widget = NewWidget(c.widgetOpts...) + c.widgetOpts = nil +} + +// WidgetAt implements WidgetLocator. +func (c *Container) WidgetAt(x int, y int) HasWidget { + c.init.Do() + + p := img.Point{x, y} + + if !p.In(c.GetWidget().Rect) { + return nil + } + + for _, ch := range c.children { + if wl, ok := ch.(Locater); ok { + w := wl.WidgetAt(x, y) + if w != nil { + return w + } + + continue + } + + if p.In(ch.GetWidget().Rect) { + return ch + } + } + + return c +} diff --git a/internal/ebitenui/widget/layout.go b/internal/ebitenui/widget/layout.go new file mode 100644 index 00000000..9af1a136 --- /dev/null +++ b/internal/ebitenui/widget/layout.go @@ -0,0 +1,59 @@ +package widget + +import ( + "image" +) + +type Layouter interface { + PreferredSize(widgets []PreferredSizeLocateableWidget) (int, int) + Layout(widgets []PreferredSizeLocateableWidget, rect image.Rectangle) +} + +type Relayoutable interface { + RequestRelayout() +} + +type Locateable interface { + SetLocation(rect image.Rectangle) +} + +type Locater interface { + WidgetAt(x int, y int) HasWidget +} + +type Insets struct { + Top int + Left int + Right int + Bottom int +} + +type Direction int + +const ( + DirectionHorizontal = Direction(iota) + DirectionVertical +) + +func NewInsetsSimple(widthHeight int) Insets { + return Insets{ + Top: widthHeight, + Left: widthHeight, + Right: widthHeight, + Bottom: widthHeight, + } +} + +func (i Insets) Apply(rect image.Rectangle) image.Rectangle { + rect.Min = rect.Min.Add(image.Point{i.Left, i.Top}) + rect.Max = rect.Max.Sub(image.Point{i.Right, i.Bottom}) + return rect +} + +func (i Insets) Dx() int { + return i.Left + i.Right +} + +func (i Insets) Dy() int { + return i.Top + i.Bottom +} diff --git a/internal/ebitenui/widget/multionce.go b/internal/ebitenui/widget/multionce.go new file mode 100644 index 00000000..b09eaaab --- /dev/null +++ b/internal/ebitenui/widget/multionce.go @@ -0,0 +1,30 @@ +package widget + +import "sync" + +// MultiOnce works like sync.Once, but can execute any number of functions. +type MultiOnce struct { + once sync.Once + funcs []func() +} + +// Append adds f to the list of functions to be executed. If Do has been called already, +// calling Append will do nothing. +func (m *MultiOnce) Append(f func()) { + m.funcs = append(m.funcs, f) +} + +// Do executes all functions added using Append. +// +// Do executes the list of functions exactly once. Calling Do a second time will do nothing. +func (m *MultiOnce) Do() { + m.once.Do(func() { + defer func() { + m.funcs = nil + }() + + for _, f := range m.funcs { + f() + } + }) +} diff --git a/internal/ebitenui/widget/rowlayout.go b/internal/ebitenui/widget/rowlayout.go new file mode 100644 index 00000000..3d3d4af7 --- /dev/null +++ b/internal/ebitenui/widget/rowlayout.go @@ -0,0 +1,190 @@ +package widget + +import "image" + +// RowLayout layouts widgets in either a single row or a single column, +// optionally stretching them in the other direction. +// +// Widget.LayoutData of widgets being layouted by RowLayout need to be of type RowLayoutData. +type RowLayout struct { + direction Direction + padding Insets + spacing int +} + +type RowLayoutOptions struct { +} + +// RowLayoutOpt is a function that configures r. +type RowLayoutOpt func(r *RowLayout) + +// RowLayoutData specifies layout settings for a widget. +type RowLayoutData struct { + // Position specifies the anchoring position for the direction that is not the primary direction of the layout. + Position RowLayoutPosition + + // Stretch specifies whether to stretch in the direction that is not the primary direction of the layout. + Stretch bool + + // MaxWidth specifies the maximum width. + MaxWidth int + + // MaxHeight specifies the maximum height. + MaxHeight int +} + +// RowLayoutPosition is the type used to specify an anchoring position. +type RowLayoutPosition int + +const ( + // RowLayoutPositionStart is the anchoring position for "left" (in the horizontal direction) or "top" (in the vertical direction.) + RowLayoutPositionStart = RowLayoutPosition(iota) + + // RowLayoutPositionCenter is the center anchoring position. + RowLayoutPositionCenter + + // RowLayoutPositionEnd is the anchoring position for "right" (in the horizontal direction) or "bottom" (in the vertical direction.) + RowLayoutPositionEnd +) + +// RowLayoutOpts contains functions that configure a RowLayout. +var RowLayoutOpts RowLayoutOptions + +// NewRowLayout constructs a new RowLayout, configured by opts. +func NewRowLayout(opts ...RowLayoutOpt) *RowLayout { + r := &RowLayout{} + + for _, o := range opts { + o(r) + } + + return r +} + +// Direction configures a row layout to layout widgets in the primary direction d. This will also switch the meaning +// of any widget's RowLayoutData.Position and RowLayoutData.Stretch to the other direction. +func (o RowLayoutOptions) Direction(d Direction) RowLayoutOpt { + return func(r *RowLayout) { + r.direction = d + } +} + +// Padding configures a row layout to use padding i. +func (o RowLayoutOptions) Padding(i Insets) RowLayoutOpt { + return func(r *RowLayout) { + r.padding = i + } +} + +// Spacing configures a row layout to separate widgets by spacing s. +func (o RowLayoutOptions) Spacing(s int) RowLayoutOpt { + return func(f *RowLayout) { + f.spacing = s + } +} + +// PreferredSize implements Layouter. +func (r *RowLayout) PreferredSize(widgets []PreferredSizeLocateableWidget) (int, int) { + rect := image.Rectangle{} + r.layout(widgets, image.Rectangle{}, false, func(w PreferredSizeLocateableWidget, wr image.Rectangle) { + rect = rect.Union(wr) + }) + return rect.Dx() + r.padding.Dx(), rect.Dy() + r.padding.Dy() +} + +// Layout implements Layouter. +func (r *RowLayout) Layout(widgets []PreferredSizeLocateableWidget, rect image.Rectangle) { + r.layout(widgets, rect, true, func(w PreferredSizeLocateableWidget, wr image.Rectangle) { + w.SetLocation(wr) + }) +} + +func (r *RowLayout) layout(widgets []PreferredSizeLocateableWidget, rect image.Rectangle, usePosition bool, locationFunc func(w PreferredSizeLocateableWidget, wr image.Rectangle)) { + if len(widgets) == 0 { + return + } + + rect = r.padding.Apply(rect) + x, y := 0, 0 + + for _, widget := range widgets { + wx, wy := x, y + ww, wh := widget.PreferredSize() + + ld := widget.GetWidget().LayoutData + if rld, ok := ld.(RowLayoutData); ok { + wx, wy, ww, wh = r.applyLayoutData(rld, wx, wy, ww, wh, usePosition, rect, x, y) + } + + wr := image.Rect(0, 0, ww, wh) + wr = wr.Add(rect.Min) + wr = wr.Add(image.Point{wx, wy}) + locationFunc(widget, wr) + + if r.direction == DirectionHorizontal { + x += ww + r.spacing + } else { + y += wh + r.spacing + } + } +} + +func (r *RowLayout) applyLayoutData(ld RowLayoutData, wx int, wy int, ww int, wh int, usePosition bool, rect image.Rectangle, x int, y int) (int, int, int, int) { + if usePosition { + ww, wh = r.applyStretch(ld, ww, wh, rect) + } + + ww, wh = r.applyMaxSize(ld, ww, wh) + + if usePosition { + wx, wy = r.applyPosition(ld, wx, wy, ww, wh, rect, x, y) + } + + return wx, wy, ww, wh +} + +func (r *RowLayout) applyStretch(ld RowLayoutData, ww int, wh int, rect image.Rectangle) (int, int) { + if !ld.Stretch { + return ww, wh + } + + if r.direction == DirectionHorizontal { + wh = rect.Dy() + } else { + ww = rect.Dx() + } + + return ww, wh +} + +func (r *RowLayout) applyMaxSize(ld RowLayoutData, ww int, wh int) (int, int) { + if ld.MaxWidth > 0 && ww > ld.MaxWidth { + ww = ld.MaxWidth + } + + if ld.MaxHeight > 0 && wh > ld.MaxHeight { + wh = ld.MaxHeight + } + + return ww, wh +} + +func (r *RowLayout) applyPosition(ld RowLayoutData, wx int, wy int, ww int, wh int, rect image.Rectangle, x int, y int) (int, int) { + switch ld.Position { + case RowLayoutPositionCenter: + if r.direction == DirectionHorizontal { + wy = y + (rect.Dy()-wh)/2 + } else { + wx = x + (rect.Dx()-ww)/2 + } + + case RowLayoutPositionEnd: + if r.direction == DirectionHorizontal { + wy = y + rect.Dy() - wh + } else { + wx = x + rect.Dx() - ww + } + } + + return wx, wy +} diff --git a/internal/ebitenui/widget/text.go b/internal/ebitenui/widget/text.go new file mode 100644 index 00000000..70b1e40e --- /dev/null +++ b/internal/ebitenui/widget/text.go @@ -0,0 +1,183 @@ +package widget + +import ( + "bufio" + "image" + "image/color" + "math" + "strings" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/text" + "golang.org/x/image/font" + "golang.org/x/image/math/fixed" +) + +type Text struct { + Label string + Face font.Face + Color color.Color + + widgetOpts []WidgetOpt + horizontalPosition TextPosition + verticalPosition TextPosition + + init *MultiOnce + widget *Widget + measurements textMeasurements +} + +type TextOpt func(t *Text) + +type TextPosition int + +const ( + TextPositionStart = TextPosition(iota) + TextPositionCenter + TextPositionEnd +) + +type TextOptions struct { +} + +type textMeasurements struct { + label string + face font.Face + + lines []string + lineWidths []float64 + lineHeight float64 + ascent float64 + boundingBoxWidth float64 + boundingBoxHeight float64 +} + +var TextOpts TextOptions + +func NewText(opts ...TextOpt) *Text { + t := &Text{ + init: &MultiOnce{}, + } + + t.init.Append(t.createWidget) + + for _, o := range opts { + o(t) + } + + return t +} + +func (o TextOptions) WidgetOpts(opts ...WidgetOpt) TextOpt { + return func(t *Text) { + t.widgetOpts = append(t.widgetOpts, opts...) + } +} + +func (o TextOptions) Text(label string, face font.Face, color color.Color) TextOpt { + return func(t *Text) { + t.Label = label + t.Face = face + t.Color = color + } +} + +func (o TextOptions) Position(h TextPosition, v TextPosition) TextOpt { + return func(t *Text) { + t.horizontalPosition = h + t.verticalPosition = v + } +} + +func (t *Text) GetWidget() *Widget { + t.init.Do() + return t.widget +} + +func (t *Text) SetLocation(rect image.Rectangle) { + t.init.Do() + t.widget.Rect = rect +} + +func (t *Text) PreferredSize() (int, int) { + t.init.Do() + t.measure() + return int(math.Ceil(t.measurements.boundingBoxWidth)), int(math.Ceil(t.measurements.boundingBoxHeight)) +} + +func (t *Text) Render(screen *ebiten.Image, def DeferredRenderFunc) { + t.init.Do() + t.widget.Render(screen, def) + t.draw(screen) +} + +func (t *Text) draw(screen *ebiten.Image) { + t.measure() + + r := t.widget.Rect + w := r.Dx() + p := r.Min + + switch t.verticalPosition { + case TextPositionCenter: + p = p.Add(image.Point{0, int((float64(r.Dy()) - t.measurements.boundingBoxHeight) / 2)}) + case TextPositionEnd: + p = p.Add(image.Point{0, int((float64(r.Dy()) - t.measurements.boundingBoxHeight))}) + } + + for i, line := range t.measurements.lines { + lx := p.X + switch t.horizontalPosition { + case TextPositionCenter: + lx += int(math.Round((float64(w) - t.measurements.lineWidths[i]) / 2)) + case TextPositionEnd: + lx += int(math.Ceil(float64(w) - t.measurements.lineWidths[i])) + } + + ly := int(math.Round(float64(p.Y) + t.measurements.lineHeight*float64(i) + t.measurements.ascent)) + + text.Draw(screen, line, t.Face, lx, ly, t.Color) + } +} + +func (t *Text) measure() { + if t.Label == t.measurements.label && t.Face == t.measurements.face { + return + } + + m := t.Face.Metrics() + + t.measurements = textMeasurements{ + label: t.Label, + face: t.Face, + ascent: fixedInt26_6ToFloat64(m.Ascent), + } + + fh := fixedInt26_6ToFloat64(m.Ascent + m.Descent) + t.measurements.lineHeight = fixedInt26_6ToFloat64(m.Height) + ld := t.measurements.lineHeight - fh + + s := bufio.NewScanner(strings.NewReader(t.Label)) + for s.Scan() { + line := s.Text() + t.measurements.lines = append(t.measurements.lines, line) + + lw := fixedInt26_6ToFloat64(font.MeasureString(t.Face, line)) + t.measurements.lineWidths = append(t.measurements.lineWidths, lw) + + if lw > t.measurements.boundingBoxWidth { + t.measurements.boundingBoxWidth = lw + } + } + + t.measurements.boundingBoxHeight = float64(len(t.measurements.lines))*t.measurements.lineHeight - ld +} + +func (t *Text) createWidget() { + t.widget = NewWidget(t.widgetOpts...) + t.widgetOpts = nil +} + +func fixedInt26_6ToFloat64(i fixed.Int26_6) float64 { + return float64(i) / (1 << 6) +} diff --git a/internal/ebitenui/widget/textinput.go b/internal/ebitenui/widget/textinput.go new file mode 100644 index 00000000..4654aef3 --- /dev/null +++ b/internal/ebitenui/widget/textinput.go @@ -0,0 +1,528 @@ +package widget + +import ( + img "image" + "image/color" + "math" + "strings" + "sync/atomic" + "time" + + "github.com/goplus/spx/internal/ebitenui/event" + "github.com/goplus/spx/internal/ebitenui/image" + "github.com/goplus/spx/internal/ebitenui/input" + "github.com/hajimehoshi/ebiten/v2" + "golang.org/x/image/font" +) + +type TextInput struct { + ChangedEvent *event.Event + + InputText string + + widgetOpts []WidgetOpt + caretOpts []CaretOpt + color *TextInputColor + padding Insets + face font.Face + repeatDelay time.Duration + repeatInterval time.Duration + placeholderText string + + init *MultiOnce + commandToFunc map[textInputControlCommand]textInputCommandFunc + widget *Widget + caret *Caret + text *Text + renderBuf *image.MaskedRenderBuffer + mask *image.NineSlice + cursorPosition int + state textInputState + scrollOffset int + focused bool + lastInputText string + secure bool + secureInputText string +} + +type TextInputOpt func(t *TextInput) + +type TextInputOptions struct { +} + +type TextInputChangedEventArgs struct { + TextInput *TextInput + InputText string +} + +type TextInputChangedHandlerFunc func(args *TextInputChangedEventArgs) + +type TextInputColor struct { + Idle color.Color + Disabled color.Color + Caret color.Color + DisabledCaret color.Color +} + +type textInputState func() (textInputState, bool) + +type textInputControlCommand int + +type textInputCommandFunc func() + +var TextInputOpts TextInputOptions + +const ( + textInputGoLeft = textInputControlCommand(iota + 1) + textInputGoRight + textInputGoStart + textInputGoEnd + textInputBackspace + textInputDelete +) + +var textInputKeyToCommand = map[ebiten.Key]textInputControlCommand{ + ebiten.KeyLeft: textInputGoLeft, + ebiten.KeyRight: textInputGoRight, + ebiten.KeyHome: textInputGoStart, + ebiten.KeyEnd: textInputGoEnd, + ebiten.KeyBackspace: textInputBackspace, + ebiten.KeyDelete: textInputDelete, +} + +func NewTextInput(opts ...TextInputOpt) *TextInput { + t := &TextInput{ + ChangedEvent: &event.Event{}, + + repeatDelay: 300 * time.Millisecond, + repeatInterval: 35 * time.Millisecond, + + init: &MultiOnce{}, + commandToFunc: map[textInputControlCommand]textInputCommandFunc{}, + renderBuf: image.NewMaskedRenderBuffer(), + } + t.state = t.idleState(true) + + t.commandToFunc[textInputGoLeft] = t.doGoLeft + t.commandToFunc[textInputGoRight] = t.doGoRight + t.commandToFunc[textInputGoStart] = t.doGoStart + t.commandToFunc[textInputGoEnd] = t.doGoEnd + t.commandToFunc[textInputBackspace] = t.doBackspace + t.commandToFunc[textInputDelete] = t.doDelete + + t.init.Append(t.createWidget) + + for _, o := range opts { + o(t) + } + + return t +} + +func (o TextInputOptions) WidgetOpts(opts ...WidgetOpt) TextInputOpt { + return func(t *TextInput) { + t.widgetOpts = append(t.widgetOpts, opts...) + } +} + +func (o TextInputOptions) CaretOpts(opts ...CaretOpt) TextInputOpt { + return func(t *TextInput) { + t.caretOpts = append(t.caretOpts, opts...) + } +} + +func (o TextInputOptions) ChangedHandler(f TextInputChangedHandlerFunc) TextInputOpt { + return func(t *TextInput) { + t.ChangedEvent.AddHandler(func(args interface{}) { + f(args.(*TextInputChangedEventArgs)) + }) + } +} + +func (o TextInputOptions) Color(c *TextInputColor) TextInputOpt { + return func(t *TextInput) { + t.color = c + } +} + +func (o TextInputOptions) Padding(i Insets) TextInputOpt { + return func(t *TextInput) { + t.padding = i + } +} + +func (o TextInputOptions) Face(f font.Face) TextInputOpt { + return func(t *TextInput) { + t.face = f + } +} + +func (o TextInputOptions) RepeatInterval(i time.Duration) TextInputOpt { + return func(t *TextInput) { + t.repeatInterval = i + } +} + +func (o TextInputOptions) Placeholder(s string) TextInputOpt { + return func(t *TextInput) { + t.placeholderText = s + } +} + +func (o TextInputOptions) Secure(b bool) TextInputOpt { + return func(t *TextInput) { + t.secure = b + } +} + +func (t *TextInput) GetWidget() *Widget { + t.init.Do() + return t.widget +} + +func (t *TextInput) SetLocation(rect img.Rectangle) { + t.init.Do() + t.widget.Rect = rect +} + +func (t *TextInput) PreferredSize() (int, int) { + t.init.Do() + _, h := t.caret.PreferredSize() + return 50, h + t.padding.Top + t.padding.Bottom +} + +func (t *TextInput) Render(screen *ebiten.Image, def DeferredRenderFunc) { + t.init.Do() + + t.text.GetWidget().Disabled = t.widget.Disabled + + if t.cursorPosition > len([]rune(t.InputText)) { + t.cursorPosition = len([]rune(t.InputText)) + } + + for { + newState, rerun := t.state() + if newState != nil { + t.state = newState + } + if !rerun { + break + } + } + + defer func() { + t.lastInputText = t.InputText + }() + + if t.InputText != t.lastInputText { + t.ChangedEvent.Fire(&TextInputChangedEventArgs{ + TextInput: t, + InputText: t.InputText, + }) + + if t.secure { + t.secureInputText = strings.Repeat("*", len([]rune(t.InputText))) + } + } + + t.widget.Render(screen, def) + t.renderTextAndCaret(screen, def) +} + +func (t *TextInput) idleState(newKeyOrCommand bool) textInputState { + return func() (textInputState, bool) { + if !t.focused { + return t.idleState(true), false + } + + chars := input.InputChars() + if len(chars) > 0 { + return t.charsInputState(chars), true + } + + st := textInputCheckForCommand(t, newKeyOrCommand) + if st != nil { + return st, true + } + + if input.MouseButtonJustPressedLayer(ebiten.MouseButtonLeft, t.widget.EffectiveInputLayer()) { + t.doGoXY(input.CursorPosition()) + } + + return t.idleState(true), false + } +} + +func textInputCheckForCommand(t *TextInput, newKeyOrCommand bool) textInputState { + for key, cmd := range textInputKeyToCommand { + if !input.KeyPressed(key) { + continue + } + + var delay time.Duration + if newKeyOrCommand { + delay = t.repeatDelay + } else { + delay = t.repeatInterval + } + + return t.commandState(cmd, key, delay, nil, nil) + } + + return nil +} + +func (t *TextInput) charsInputState(c []rune) textInputState { + return func() (textInputState, bool) { + if !t.widget.Disabled { + t.doInsert(c) + } + + t.caret.ResetBlinking() + + return t.idleState(true), false + } +} + +func (t *TextInput) commandState(cmd textInputControlCommand, key ebiten.Key, delay time.Duration, timer *time.Timer, expired *atomic.Value) textInputState { + return func() (textInputState, bool) { + if !input.KeyPressed(key) { + return t.idleState(true), true + } + + if timer != nil && expired.Load().(bool) { + return t.idleState(false), true + } + + if timer == nil { + t.commandToFunc[cmd]() + + expired = &atomic.Value{} + expired.Store(false) + + timer = time.AfterFunc(delay, func() { + expired.Store(true) + }) + + return t.commandState(cmd, key, delay, timer, expired), false + } + + return nil, false + } +} + +func (t *TextInput) doInsert(c []rune) { + s := string(insertChars([]rune(t.InputText), c, t.cursorPosition)) + t.InputText = s + t.cursorPosition += len(c) +} + +func (t *TextInput) doGoLeft() { + if t.cursorPosition > 0 { + t.cursorPosition-- + } + t.caret.ResetBlinking() +} + +func (t *TextInput) doGoRight() { + if t.cursorPosition < len([]rune(t.InputText)) { + t.cursorPosition++ + } + t.caret.ResetBlinking() +} + +func (t *TextInput) doGoStart() { + t.cursorPosition = 0 + t.caret.ResetBlinking() +} + +func (t *TextInput) doGoEnd() { + t.cursorPosition = len([]rune(t.InputText)) + t.caret.ResetBlinking() +} + +func (t *TextInput) doGoXY(x int, y int) { + p := img.Point{x, y} + if p.In(t.widget.Rect) { + tr := t.padding.Apply(t.widget.Rect) + if x < tr.Min.X { + x = tr.Min.X + } + if x > tr.Max.X { + x = tr.Max.X + } + + t.cursorPosition = fontStringIndex([]rune(t.InputText), t.face, x-t.scrollOffset-tr.Min.X) + t.caret.ResetBlinking() + } +} + +func (t *TextInput) doBackspace() { + if !t.widget.Disabled && t.cursorPosition > 0 { + t.InputText = string(removeChar([]rune(t.InputText), t.cursorPosition-1)) + t.cursorPosition-- + } + t.caret.ResetBlinking() +} + +func (t *TextInput) doDelete() { + if !t.widget.Disabled && t.cursorPosition < len([]rune(t.InputText)) { + t.InputText = string(removeChar([]rune(t.InputText), t.cursorPosition)) + } + t.caret.ResetBlinking() +} + +func insertChars(r []rune, c []rune, pos int) []rune { + res := make([]rune, len(r)+len(c)) + copy(res, r[:pos]) + copy(res[pos:], c) + copy(res[pos+len(c):], r[pos:]) + return res +} + +func removeChar(r []rune, pos int) []rune { + res := make([]rune, len(r)-1) + copy(res, r[:pos]) + copy(res[pos:], r[pos+1:]) + return res +} + +func (t *TextInput) renderTextAndCaret(screen *ebiten.Image, def DeferredRenderFunc) { + t.renderBuf.Draw(screen, + func(buf *ebiten.Image) { + t.drawTextAndCaret(buf, def) + }, + func(buf *ebiten.Image) { + rect := t.widget.Rect + t.mask.Draw(buf, rect.Dx()-t.padding.Left-t.padding.Right, rect.Dy()-t.padding.Top-t.padding.Bottom, + func(opts *ebiten.DrawImageOptions) { + opts.GeoM.Translate(float64(rect.Min.X+t.padding.Left), float64(rect.Min.Y+t.padding.Top)) + opts.CompositeMode = ebiten.CompositeModeCopy + }) + }) +} + +func (t *TextInput) drawTextAndCaret(screen *ebiten.Image, def DeferredRenderFunc) { + rect := t.widget.Rect + tr := rect + tr = tr.Add(img.Point{t.padding.Left, t.padding.Top}) + + inputStr := t.InputText + if t.secure { + inputStr = t.secureInputText + } + + cx := 0 + if t.focused { + sub := string([]rune(inputStr)[:t.cursorPosition]) + cx = fontAdvance(sub, t.face) + + dx := tr.Min.X + t.scrollOffset + cx + t.caret.Width + t.padding.Right - rect.Max.X + if dx > 0 { + t.scrollOffset -= dx + } + + dx = tr.Min.X + t.scrollOffset + cx - t.padding.Left - rect.Min.X + if dx < 0 { + t.scrollOffset -= dx + } + } + + tr = tr.Add(img.Point{t.scrollOffset, 0}) + + t.text.SetLocation(tr) + if len([]rune(t.InputText)) > 0 { + t.text.Label = inputStr + } else { + t.text.Label = t.placeholderText + } + if t.widget.Disabled || len([]rune(t.InputText)) == 0 { + t.text.Color = t.color.Disabled + } else { + t.text.Color = t.color.Idle + } + t.text.Render(screen, def) + + if t.focused { + if t.widget.Disabled { + t.caret.Color = t.color.DisabledCaret + } else { + t.caret.Color = t.color.Caret + } + + tr = tr.Add(img.Point{cx, 0}) + t.caret.SetLocation(tr) + + t.caret.Render(screen, def) + } +} + +func (t *TextInput) Focus(focused bool) { + t.init.Do() + WidgetFireFocusEvent(t.widget, focused) + t.caret.resetBlinking() + t.focused = focused +} + +func (t *TextInput) createWidget() { + t.widget = NewWidget(t.widgetOpts...) + t.widgetOpts = nil + + t.caret = NewCaret(append(t.caretOpts, CaretOpts.Color(t.color.Caret))...) + t.caretOpts = nil + + t.text = NewText(TextOpts.Text("", t.face, color.White)) + + t.mask = image.NewNineSliceColor(color.RGBA{255, 0, 255, 255}) +} + +func fontAdvance(s string, f font.Face) int { + _, a := font.BoundString(f, s) + return int(math.Round(fixedInt26_6ToFloat64(a))) +} + +// fontStringIndex returns an index into r that corresponds closest to pixel position x +// when string(r) is drawn using f. Pixel position x==0 corresponds to r[0]. +func fontStringIndex(r []rune, f font.Face, x int) int { + start := 0 + end := len(r) + p := 0 +loop: + for { + p = start + (end-start)/2 + sub := string(r[:p]) + a := fontAdvance(sub, f) + + switch { + // x is right of advance + case x > a: + if p == start { + break loop + } + + start = p + + // x is left of advance + case x < a: + if end == p { + break loop + } + + end = p + + // x matches advance exactly + default: + return p + } + } + + if len(r) > 0 { + a1 := fontAdvance(string(r[:p]), f) + a2 := fontAdvance(string(r[:p+1]), f) + if math.Abs(float64(x-a2)) < math.Abs(float64(x-a1)) { + p++ + } + } + + return p +} diff --git a/internal/ebitenui/widget/widget.go b/internal/ebitenui/widget/widget.go new file mode 100644 index 00000000..6041961b --- /dev/null +++ b/internal/ebitenui/widget/widget.go @@ -0,0 +1,365 @@ +package widget + +import ( + "image" + + "github.com/goplus/spx/internal/ebitenui/event" + "github.com/goplus/spx/internal/ebitenui/input" + + "github.com/hajimehoshi/ebiten/v2" +) + +// A Widget is an abstraction of a user interface widget, such as a button. Actual widget implementations +// "have" a Widget in their internal structure. +type Widget struct { + // Rect specifies the widget's position on screen. It is usually not set directly, but a Layouter is + // used to set the position in relation to other widgets or the space available. + Rect image.Rectangle + + // LayoutData specifies additional optional data for a Layouter that is used to layout this widget's + // parent container. The exact type depends on the layout being used, for example, GridLayout requires + // GridLayoutData to be used. + LayoutData interface{} + + // Disabled specifies whether the widget is disabled, whatever that means. Disabled widgets should + // usually render in some sort of "greyed out" visual state, and not react to user input. + // + // Not reacting to user input depends on the actual implementation. For example, List will not allow + // entry selection via clicking, but the scrollbars will still be usable. The reasoning is that from + // the user's perspective, scrolling does not change state, but only the display of that state. + Disabled bool + + // CursorEnterEvent fires an event with *WidgetCursorEnterEventArgs when the cursor enters the widget's Rect. + CursorEnterEvent *event.Event + + // CursorExitEvent fires an event with *WidgetCursorExitEventArgs when the cursor exits the widget's Rect. + CursorExitEvent *event.Event + + // MouseButtonPressedEvent fires an event with *WidgetMouseButtonPressedEventArgs when a mouse button is pressed + // while the cursor is inside the widget's Rect. + MouseButtonPressedEvent *event.Event + + // MouseButtonReleasedEvent fires an event with *WidgetMouseButtonReleasedEventArgs when a mouse button is released + // while the cursor is inside the widget's Rect. + MouseButtonReleasedEvent *event.Event + + // ScrolledEvent fires an event with *WidgetScrolledEventArgs when the mouse wheel is scrolled while + // the cursor is inside the widget's Rect. + ScrolledEvent *event.Event + + FocusEvent *event.Event + + parent *Widget + lastUpdateCursorEntered bool + lastUpdateMouseLeftPressed bool + mouseLeftPressedInside bool + inputLayer *input.Layer +} + +// WidgetOpt is a function that configures w. +type WidgetOpt func(w *Widget) //nolint:golint + +// HasWidget must be implemented by concrete widget types to get their Widget. +type HasWidget interface { + GetWidget() *Widget +} + +// Renderer may be implemented by concrete widget types that can render onto the screen. +type Renderer interface { + // Render renders the widget onto screen. def may be called to defer additional rendering. + Render(screen *ebiten.Image, def DeferredRenderFunc) +} + +type Focuser interface { + Focus(focused bool) +} + +// RenderFunc is a function that renders a widget onto screen. def may be called to defer +// additional rendering. +type RenderFunc func(screen *ebiten.Image, def DeferredRenderFunc) + +// DeferredRenderFunc is a function that stores r for deferred execution. +type DeferredRenderFunc func(r RenderFunc) + +// PreferredSizer may be implemented by concrete widget types that can report a preferred size. +type PreferredSizer interface { + PreferredSize() (int, int) +} + +// WidgetCursorEnterEventArgs are the arguments for cursor enter events. +type WidgetCursorEnterEventArgs struct { //nolint:golint + Widget *Widget +} + +// WidgetCursorExitEventArgs are the arguments for cursor exit events. +type WidgetCursorExitEventArgs struct { //nolint:golint + Widget *Widget +} + +// WidgetMouseButtonPressedEventArgs are the arguments for mouse button press events. +type WidgetMouseButtonPressedEventArgs struct { //nolint:golint + Widget *Widget + Button ebiten.MouseButton + + // OffsetX is the x offset relative to the widget's Rect. + OffsetX int + + // OffsetY is the y offset relative to the widget's Rect. + OffsetY int +} + +// WidgetMouseButtonReleasedEventArgs are the arguments for mouse button release events. +type WidgetMouseButtonReleasedEventArgs struct { //nolint:golint + Widget *Widget + Button ebiten.MouseButton + + // Inside specifies whether the button has been released inside the widget's Rect. + Inside bool + + // OffsetX is the x offset relative to the widget's Rect. + OffsetX int + + // OffsetY is the y offset relative to the widget's Rect. + OffsetY int +} + +// WidgetScrolledEventArgs are the arguments for mouse wheel scroll events. +type WidgetScrolledEventArgs struct { //nolint:golint + Widget *Widget + X float64 + Y float64 +} + +type WidgetFocusEventArgs struct { //nolint:golint + Widget *Widget + Focused bool +} + +// WidgetCursorEnterHandlerFunc is a function that handles cursor enter events. +type WidgetCursorEnterHandlerFunc func(args *WidgetCursorEnterEventArgs) //nolint:golint + +// WidgetCursorExitHandlerFunc is a function that handles cursor exit events. +type WidgetCursorExitHandlerFunc func(args *WidgetCursorExitEventArgs) //nolint:golint + +// WidgetMouseButtonPressedHandlerFunc is a function that handles mouse button press events. +type WidgetMouseButtonPressedHandlerFunc func(args *WidgetMouseButtonPressedEventArgs) //nolint:golint + +// WidgetMouseButtonReleasedHandlerFunc is a function that handles mouse button release events. +type WidgetMouseButtonReleasedHandlerFunc func(args *WidgetMouseButtonReleasedEventArgs) //nolint:golint + +// WidgetScrolledHandlerFunc is a function that handles mouse wheel scroll events. +type WidgetScrolledHandlerFunc func(args *WidgetScrolledEventArgs) //nolint:golint + +type WidgetOptions struct { //nolint:golint +} + +// WidgetOpts contains functions that configure a Widget. +var WidgetOpts WidgetOptions + +var deferredRenders []RenderFunc + +// NewWidget constructs a new Widget configured with opts. +func NewWidget(opts ...WidgetOpt) *Widget { + w := &Widget{ + CursorEnterEvent: &event.Event{}, + CursorExitEvent: &event.Event{}, + MouseButtonPressedEvent: &event.Event{}, + MouseButtonReleasedEvent: &event.Event{}, + ScrolledEvent: &event.Event{}, + FocusEvent: &event.Event{}, + } + + for _, o := range opts { + o(w) + } + + return w +} + +// WithLayoutData configures a Widget with layout data ld. +func (o WidgetOptions) LayoutData(ld interface{}) WidgetOpt { + return func(w *Widget) { + w.LayoutData = ld + } +} + +// WithCursorEnterHandler configures a Widget with cursor enter event handler f. +func (o WidgetOptions) CursorEnterHandler(f WidgetCursorEnterHandlerFunc) WidgetOpt { + return func(w *Widget) { + w.CursorEnterEvent.AddHandler(func(args interface{}) { + f(args.(*WidgetCursorEnterEventArgs)) + }) + } +} + +// WithCursorExitHandler configures a Widget with cursor exit event handler f. +func (o WidgetOptions) CursorExitHandler(f WidgetCursorExitHandlerFunc) WidgetOpt { + return func(w *Widget) { + w.CursorExitEvent.AddHandler(func(args interface{}) { + f(args.(*WidgetCursorExitEventArgs)) + }) + } +} + +// WithMouseButtonPressedHandler configures a Widget with mouse button press event handler f. +func (o WidgetOptions) MouseButtonPressedHandler(f WidgetMouseButtonPressedHandlerFunc) WidgetOpt { + return func(w *Widget) { + w.MouseButtonPressedEvent.AddHandler(func(args interface{}) { + f(args.(*WidgetMouseButtonPressedEventArgs)) + }) + } +} + +// WithMouseButtonReleasedHandler configures a Widget with mouse button release event handler f. +func (o WidgetOptions) MouseButtonReleasedHandler(f WidgetMouseButtonReleasedHandlerFunc) WidgetOpt { + return func(w *Widget) { + w.MouseButtonReleasedEvent.AddHandler(func(args interface{}) { + f(args.(*WidgetMouseButtonReleasedEventArgs)) + }) + } +} + +// WithScrolledHandler configures a Widget with mouse wheel scroll event handler f. +func (o WidgetOptions) ScrolledHandler(f WidgetScrolledHandlerFunc) WidgetOpt { + return func(w *Widget) { + w.ScrolledEvent.AddHandler(func(args interface{}) { + f(args.(*WidgetScrolledEventArgs)) + }) + } +} + +func (w *Widget) drawImageOptions(opts *ebiten.DrawImageOptions) { + opts.GeoM.Translate(float64(w.Rect.Min.X), float64(w.Rect.Min.Y)) +} + +// EffectiveInputLayer returns w's effective input layer. If w does not have an input layer, +// or if the input layer is no longer valid, it returns w's parent widget's effective input layer. +// If w does not have a parent widget, it returns input.DefaultLayer. +func (w *Widget) EffectiveInputLayer() *input.Layer { + l := w.inputLayer + if l != nil && !l.Valid() { + l = nil + } + + if l == nil { + if w.parent == nil { + return &input.DefaultLayer + } + + return w.parent.EffectiveInputLayer() + } + + return l +} + +// Render renders w onto screen. Since Widget is only an abstraction, it does not actually draw +// anything, but it is still responsible for firing events. Concrete widget implementations should +// always call this method first before rendering themselves. +func (w *Widget) Render(screen *ebiten.Image, def DeferredRenderFunc) { + w.fireEvents() +} + +func (w *Widget) fireEvents() { + x, y := input.CursorPosition() + p := image.Point{x, y} + layer := w.EffectiveInputLayer() + inside := p.In(w.Rect) + + entered := inside && layer.ActiveFor(x, y, input.LayerEventTypeAny) + if entered != w.lastUpdateCursorEntered { + if entered { + w.CursorEnterEvent.Fire(&WidgetCursorEnterEventArgs{ + Widget: w, + }) + } else { + w.CursorExitEvent.Fire(&WidgetCursorExitEventArgs{ + Widget: w, + }) + } + + w.lastUpdateCursorEntered = entered + } + + if inside && input.MouseButtonJustPressedLayer(ebiten.MouseButtonLeft, layer) { + w.lastUpdateMouseLeftPressed = true + w.mouseLeftPressedInside = inside + + off := p.Sub(w.Rect.Min) + w.MouseButtonPressedEvent.Fire(&WidgetMouseButtonPressedEventArgs{ + Widget: w, + Button: ebiten.MouseButtonLeft, + OffsetX: off.X, + OffsetY: off.Y, + }) + } + + if w.lastUpdateMouseLeftPressed && !input.MouseButtonPressedLayer(ebiten.MouseButtonLeft, layer) { + w.lastUpdateMouseLeftPressed = false + + off := p.Sub(w.Rect.Min) + w.MouseButtonReleasedEvent.Fire(&WidgetMouseButtonReleasedEventArgs{ + Widget: w, + Button: ebiten.MouseButtonLeft, + Inside: inside, + OffsetX: off.X, + OffsetY: off.Y, + }) + } + + scrollX, scrollY := input.WheelLayer(layer) + if inside && (scrollX != 0 || scrollY != 0) { + w.ScrolledEvent.Fire(&WidgetScrolledEventArgs{ + Widget: w, + X: scrollX, + Y: scrollY, + }) + } +} + +// SetLocation sets w's position to rect. This is usually not called directly, but by a layout. +func (w *Widget) SetLocation(rect image.Rectangle) { + w.Rect = rect +} + +// ElevateToNewInputLayer adds l to the top of the input layer stack, then sets w's input layer to l. +func (w *Widget) ElevateToNewInputLayer(l *input.Layer) { + input.AddLayer(l) + w.inputLayer = l +} + +func (w *Widget) Parent() *Widget { + return w.parent +} + +func WidgetFireFocusEvent(w *Widget, focused bool) { //nolint:golint + w.FocusEvent.Fire(&WidgetFocusEventArgs{ + Widget: w, + Focused: focused, + }) +} + +// RenderWithDeferred renders r to screen. This function should not be called directly. +func RenderWithDeferred(screen *ebiten.Image, rs []Renderer) { + for _, r := range rs { + appendToDeferredRenderQueue(r.Render) + } + + renderDeferredRenderQueue(screen) +} + +func renderDeferredRenderQueue(screen *ebiten.Image) { + defer func(d []RenderFunc) { + deferredRenders = d[:0] + }(deferredRenders) + + for len(deferredRenders) > 0 { + r := deferredRenders[0] + deferredRenders = deferredRenders[1:] + + r(screen, appendToDeferredRenderQueue) + } +} + +func appendToDeferredRenderQueue(r RenderFunc) { + deferredRenders = append(deferredRenders, r) +} diff --git a/internal/gdi/font.go b/internal/gdi/font/font.go similarity index 75% rename from internal/gdi/font.go rename to internal/gdi/font/font.go index 9e67cd8e..6912970f 100644 --- a/internal/gdi/font.go +++ b/internal/gdi/font/font.go @@ -1,7 +1,7 @@ //go:build !canvas // +build !canvas -package gdi +package font import ( "fmt" @@ -18,24 +18,22 @@ import ( // ------------------------------------------------------------------------------------- -type Font = font.Face - -type DefaultFont struct { +type Default struct { ascii font.Face songti font.Face done chan error once sync.Once } -type FontOptions = truetype.Options +type Options = truetype.Options -func NewDefaultFont(options *FontOptions) *DefaultFont { - p := &DefaultFont{done: make(chan error)} +func NewDefault(options *Options) *Default { + p := &Default{done: make(chan error)} go p.init(options) return p } -func (p *DefaultFont) Close() (err error) { +func (p *Default) Close() (err error) { if f := p.ascii; f != nil { f.Close() } @@ -45,7 +43,7 @@ func (p *DefaultFont) Close() (err error) { return nil } -func (p *DefaultFont) ensureInited() { +func (p *Default) ensureInited() { p.once.Do(func() { <-p.done }) @@ -56,7 +54,7 @@ type fontNameInit struct { inited bool } -func (p *DefaultFont) init(options *truetype.Options) { +func (p *Default) init(options *truetype.Options) { fontFaceNames := map[string]*fontNameInit{ "Times New Roman": {paths: []string{"Times New Roman Bold.ttf", "Times New Roman.ttf", "Times.ttf"}}, "SimSun": {paths: []string{"SimSun.ttf", "SimSun.ttc", "Songti.ttc"}}, @@ -78,7 +76,7 @@ func (p *DefaultFont) init(options *truetype.Options) { p.done <- nil } -func (p *DefaultFont) findFontAtPath( +func (p *Default) findFontAtPath( name string, findPath string, fontNames []string, options *truetype.Options) bool { for _, fontName := range fontNames { tryFile := path.Join(findPath, fontName) @@ -89,7 +87,7 @@ func (p *DefaultFont) findFontAtPath( return false } -func (p *DefaultFont) tryFontFile(name, tryFile string, options *truetype.Options) bool { +func (p *Default) tryFontFile(name, tryFile string, options *truetype.Options) bool { fp, err := fsutil.OpenFile(tryFile) if err != nil { return false @@ -116,7 +114,7 @@ func (p *DefaultFont) tryFontFile(name, tryFile string, options *truetype.Option return true } -func (p *DefaultFont) Glyph(dot fixed.Point26_6, r rune) ( +func (p *Default) Glyph(dot fixed.Point26_6, r rune) ( dr image.Rectangle, mask image.Image, maskp image.Point, advance fixed.Int26_6, ok bool) { p.ensureInited() if r < 0x100 { @@ -125,7 +123,7 @@ func (p *DefaultFont) Glyph(dot fixed.Point26_6, r rune) ( return p.songti.Glyph(dot, r) } -func (p *DefaultFont) GlyphBounds(r rune) (bounds fixed.Rectangle26_6, advance fixed.Int26_6, ok bool) { +func (p *Default) GlyphBounds(r rune) (bounds fixed.Rectangle26_6, advance fixed.Int26_6, ok bool) { p.ensureInited() if r < 0x100 { return p.ascii.GlyphBounds(r) @@ -133,7 +131,7 @@ func (p *DefaultFont) GlyphBounds(r rune) (bounds fixed.Rectangle26_6, advance f return p.songti.GlyphBounds(r) } -func (p *DefaultFont) GlyphAdvance(r rune) (advance fixed.Int26_6, ok bool) { +func (p *Default) GlyphAdvance(r rune) (advance fixed.Int26_6, ok bool) { p.ensureInited() if r < 0x100 { return p.ascii.GlyphAdvance(r) @@ -141,12 +139,12 @@ func (p *DefaultFont) GlyphAdvance(r rune) (advance fixed.Int26_6, ok bool) { return p.songti.GlyphAdvance(r) } -func (p *DefaultFont) Kern(r0, r1 rune) fixed.Int26_6 { +func (p *Default) Kern(r0, r1 rune) fixed.Int26_6 { p.ensureInited() return p.ascii.Kern(r0, r1) } -func (p *DefaultFont) Metrics() font.Metrics { +func (p *Default) Metrics() font.Metrics { p.ensureInited() return p.ascii.Metrics() } diff --git a/internal/gdi/font_canvas.go b/internal/gdi/font/font_canvas.go similarity index 97% rename from internal/gdi/font_canvas.go rename to internal/gdi/font/font_canvas.go index 6b9cf661..ed9f629e 100644 --- a/internal/gdi/font_canvas.go +++ b/internal/gdi/font/font_canvas.go @@ -1,7 +1,7 @@ //go:build canvas // +build canvas -package gdi +package font import ( "golang.org/x/image/font" diff --git a/internal/gdi/font_darwin.go b/internal/gdi/font/font_darwin.go similarity index 95% rename from internal/gdi/font_darwin.go rename to internal/gdi/font/font_darwin.go index 5d364364..b792baab 100644 --- a/internal/gdi/font_darwin.go +++ b/internal/gdi/font/font_darwin.go @@ -1,4 +1,4 @@ -package gdi +package font // ------------------------------------------------------------------------------------- diff --git a/internal/gdi/font_js.go b/internal/gdi/font/font_js.go similarity index 91% rename from internal/gdi/font_js.go rename to internal/gdi/font/font_js.go index 873328d7..45aed595 100644 --- a/internal/gdi/font_js.go +++ b/internal/gdi/font/font_js.go @@ -1,4 +1,4 @@ -package gdi +package font // ------------------------------------------------------------------------------------- diff --git a/internal/gdi/font_linux.go b/internal/gdi/font/font_linux.go similarity index 94% rename from internal/gdi/font_linux.go rename to internal/gdi/font/font_linux.go index 13e49e39..8ccb3723 100644 --- a/internal/gdi/font_linux.go +++ b/internal/gdi/font/font_linux.go @@ -1,4 +1,4 @@ -package gdi +package font // ------------------------------------------------------------------------------------- diff --git a/internal/gdi/font_windows.go b/internal/gdi/font/font_windows.go similarity index 94% rename from internal/gdi/font_windows.go rename to internal/gdi/font/font_windows.go index aeb845b6..dd059b26 100644 --- a/internal/gdi/font_windows.go +++ b/internal/gdi/font/font_windows.go @@ -1,4 +1,4 @@ -package gdi +package font // ------------------------------------------------------------------------------------- diff --git a/internal/gdi/gdi.go b/internal/gdi/gdi.go index cc4f58c0..fd29d79b 100644 --- a/internal/gdi/gdi.go +++ b/internal/gdi/gdi.go @@ -6,12 +6,15 @@ package gdi import ( "image/color" + "golang.org/x/image/font" "golang.org/x/image/math/fixed" "github.com/goplus/spx/internal/gdi/text" "github.com/hajimehoshi/ebiten/v2" ) +type Font = font.Face + // ------------------------------------------------------------------------------------- // TextRender represents a text rendering engine. diff --git a/internal/gdi/rect.go b/internal/gdi/rect.go deleted file mode 100644 index 29c8730a..00000000 --- a/internal/gdi/rect.go +++ /dev/null @@ -1,241 +0,0 @@ -package gdi - -/* -import "math" - -// ------------------------------------------------------------------------------------- - -// A Point is an X, Y coordinate pair. The axes increase right and down. -type Point struct { - X, Y float64 -} - -// Add returns the vector p+q. -func (p Point) Add(q Point) Point { - return Point{p.X + q.X, p.Y + q.Y} -} - -// Sub returns the vector p-q. -func (p Point) Sub(q Point) Point { - return Point{p.X - q.X, p.Y - q.Y} -} - -// Mul returns the vector p*k. -func (p Point) Mul(k float64) Point { - return Point{p.X * k, p.Y * k} -} - -// Div returns the vector p/k. -func (p Point) Div(k float64) Point { - return Point{p.X / k, p.Y / k} -} - -// In reports whether p is in r. -func (p Point) In(r Rectangle) bool { - return r.Min.X <= p.X && p.X < r.Max.X && - r.Min.Y <= p.Y && p.Y < r.Max.Y -} - -// Mod returns the point q in r such that p.X-q.X is a multiple of r's width -// and p.Y-q.Y is a multiple of r's height. -func (p Point) Mod(r Rectangle) Point { - w, h := r.Dx(), r.Dy() - p = p.Sub(r.Min) - p.X = math.Mod(p.X, w) - if p.X < 0 { - p.X += w - } - p.Y = math.Mod(p.Y, h) - if p.Y < 0 { - p.Y += h - } - return p.Add(r.Min) -} - -// Eq reports whether p and q are equal. -func (p Point) Eq(q Point) bool { - return p == q -} - -// ZP is the zero Point. -var ZP Point - -// Pt is shorthand for Point{X, Y}. -func Pt(X, Y float64) Point { - return Point{X, Y} -} - -// A Rectangle contains the points with Min.X <= X < Max.X, Min.Y <= Y < Max.Y. -// It is well-formed if Min.X <= Max.X and likewise for Y. Points are always -// well-formed. A rectangle's methods always return well-formed outputs for -// well-formed inputs. -// -// A Rectangle is also an Image whose bounds are the rectangle itself. At -// returns color.Opaque for points in the rectangle and color.Transparent -// otherwise. -type Rectangle struct { - Min, Max Point -} - -// Dx returns r's width. -func (r Rectangle) Dx() float64 { - return r.Max.X - r.Min.X -} - -// Dy returns r's height. -func (r Rectangle) Dy() float64 { - return r.Max.Y - r.Min.Y -} - -// Size returns r's width and height. -func (r Rectangle) Size() Point { - return Point{ - r.Max.X - r.Min.X, - r.Max.Y - r.Min.Y, - } -} - -// Add returns the rectangle r translated by p. -func (r Rectangle) Add(p Point) Rectangle { - return Rectangle{ - Point{r.Min.X + p.X, r.Min.Y + p.Y}, - Point{r.Max.X + p.X, r.Max.Y + p.Y}, - } -} - -// Sub returns the rectangle r translated by -p. -func (r Rectangle) Sub(p Point) Rectangle { - return Rectangle{ - Point{r.Min.X - p.X, r.Min.Y - p.Y}, - Point{r.Max.X - p.X, r.Max.Y - p.Y}, - } -} - -// Inset returns the rectangle r inset by n, which may be negative. If either -// of r's dimensions is less than 2*n then an empty rectangle near the center -// of r will be returned. -func (r Rectangle) Inset(n float64) Rectangle { - if r.Dx() < 2*n { - r.Min.X = (r.Min.X + r.Max.X) / 2 - r.Max.X = r.Min.X - } else { - r.Min.X += n - r.Max.X -= n - } - if r.Dy() < 2*n { - r.Min.Y = (r.Min.Y + r.Max.Y) / 2 - r.Max.Y = r.Min.Y - } else { - r.Min.Y += n - r.Max.Y -= n - } - return r -} - -// Intersect returns the largest rectangle contained by both r and s. If the -// two rectangles do not overlap then the zero rectangle will be returned. -func (r Rectangle) Intersect(s Rectangle) Rectangle { - if r.Min.X < s.Min.X { - r.Min.X = s.Min.X - } - if r.Min.Y < s.Min.Y { - r.Min.Y = s.Min.Y - } - if r.Max.X > s.Max.X { - r.Max.X = s.Max.X - } - if r.Max.Y > s.Max.Y { - r.Max.Y = s.Max.Y - } - // Letting r0 and s0 be the values of r and s at the time that the method - // is called, this next line is equivalent to: - // - // if max(r0.Min.X, s0.Min.X) >= min(r0.Max.X, s0.Max.X) || likewiseForY { etc } - if r.Empty() { - return ZR - } - return r -} - -// Union returns the smallest rectangle that contains both r and s. -func (r Rectangle) Union(s Rectangle) Rectangle { - if r.Empty() { - return s - } - if s.Empty() { - return r - } - if r.Min.X > s.Min.X { - r.Min.X = s.Min.X - } - if r.Min.Y > s.Min.Y { - r.Min.Y = s.Min.Y - } - if r.Max.X < s.Max.X { - r.Max.X = s.Max.X - } - if r.Max.Y < s.Max.Y { - r.Max.Y = s.Max.Y - } - return r -} - -// Empty reports whether the rectangle contains no points. -func (r Rectangle) Empty() bool { - return r.Min.X >= r.Max.X || r.Min.Y >= r.Max.Y -} - -// Eq reports whether r and s contain the same set of points. All empty -// rectangles are considered equal. -func (r Rectangle) Eq(s Rectangle) bool { - return r == s || r.Empty() && s.Empty() -} - -// Overlaps reports whether r and s have a non-empty intersection. -func (r Rectangle) Overlaps(s Rectangle) bool { - return !r.Empty() && !s.Empty() && - r.Min.X < s.Max.X && s.Min.X < r.Max.X && - r.Min.Y < s.Max.Y && s.Min.Y < r.Max.Y -} - -// In reports whether every point in r is in s. -func (r Rectangle) In(s Rectangle) bool { - if r.Empty() { - return true - } - // Note that r.Max is an exclusive bound for r, so that r.In(s) - // does not require that r.Max.In(s). - return s.Min.X <= r.Min.X && r.Max.X <= s.Max.X && - s.Min.Y <= r.Min.Y && r.Max.Y <= s.Max.Y -} - -// Canon returns the canonical version of r. The returned rectangle has minimum -// and maximum coordinates swapped if necessary so that it is well-formed. -func (r Rectangle) Canon() Rectangle { - if r.Max.X < r.Min.X { - r.Min.X, r.Max.X = r.Max.X, r.Min.X - } - if r.Max.Y < r.Min.Y { - r.Min.Y, r.Max.Y = r.Max.Y, r.Min.Y - } - return r -} - -// ZR is the zero Rectangle. -var ZR Rectangle - -// Rect is shorthand for Rectangle{Pt(x0, y0), Pt(x1, y1)}. The returned -// rectangle has minimum and maximum coordinates swapped if necessary so that -// it is well-formed. -func Rect(x0, y0, x1, y1 float64) Rectangle { - if x0 > x1 { - x0, x1 = x1, x0 - } - if y0 > y1 { - y0, y1 = y1, y0 - } - return Rectangle{Point{x0, y0}, Point{x1, y1}} -} - -// ------------------------------------------------------------------------------------- -*/ diff --git a/say.go b/say.go index 030dc2d4..eb57a2bb 100644 --- a/say.go +++ b/say.go @@ -5,8 +5,10 @@ import ( "strconv" "strings" - "github.com/goplus/spx/internal/gdi" "golang.org/x/image/font" + + "github.com/goplus/spx/internal/gdi" + xfont "github.com/goplus/spx/internal/gdi/font" ) var ( @@ -17,17 +19,17 @@ var ( func init() { const dpi = 72 - defaultFont = gdi.NewDefaultFont(&gdi.FontOptions{ + defaultFont = xfont.NewDefault(&xfont.Options{ Size: 15, DPI: dpi, Hinting: font.HintingFull, }) - defaultFont2 = gdi.NewDefaultFont(&gdi.FontOptions{ // for stageMonitor + defaultFont2 = xfont.NewDefault(&xfont.Options{ // for stageMonitor Size: 12, DPI: dpi, Hinting: font.HintingFull, }) - defaultFontSm = gdi.NewDefaultFont(&gdi.FontOptions{ + defaultFontSm = xfont.NewDefault(&xfont.Options{ Size: 11, DPI: dpi, Hinting: font.HintingFull,