Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions container.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ type container struct {
toggledIDs map[widgetID]struct{}
textInputTextFields map[widgetID]*textinput.Field

// dropdownCloseDelay is used for delayed closing of dropdowns
dropdownCloseDelay int

used bool
}

Expand Down
144 changes: 144 additions & 0 deletions dropdown.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2025 The Ebitengine Authors

package debugui

import (
"image"
"strconv"

"github.com/hajimehoshi/ebiten/v2"
)

// Dropdown creates a dropdown menu widget that allows users to select from a list of options.
// selectedIndex is a pointer to the currently selected option index (0-based).
// options is a slice of strings representing the available choices.
// Returns an EventHandler that triggers when the selection changes.
func (c *Context) Dropdown(selectedIndex *int, options []string) EventHandler {
pc := caller()
id := c.idFromCaller(pc)
return c.wrapEventHandlerAndError(func() (EventHandler, error) {
return c.dropdown(selectedIndex, options, id)
})
}

func (c *Context) dropdown(selectedIndex *int, options []string, id widgetID) (EventHandler, error) {
if selectedIndex == nil || len(options) == 0 {
return &nullEventHandler{}, nil
}
if *selectedIndex < 0 || *selectedIndex >= len(options) {
*selectedIndex = 0
}
last := *selectedIndex

dropdownID := c.idFromString("dropdown:" + string(id))

dropdownContainer := c.container(dropdownID, 0)

// Handle delayed closing of dropdown
if dropdownContainer.dropdownCloseDelay > 0 {
dropdownContainer.dropdownCloseDelay--
if dropdownContainer.dropdownCloseDelay == 0 {
dropdownContainer.open = false
}
}

if dropdownContainer.layout.Bounds.Empty() {
dropdownContainer.open = false
}

_ = c.wrapEventHandlerAndError(func() (EventHandler, error) {
windowOptions := optionNoResize | optionNoTitle

if err := c.window("", image.Rectangle{}, windowOptions, dropdownID, func(layout ContainerLayout) {
if cnt := c.container(dropdownID, 0); cnt != nil {
if cnt.open {
c.bringToFront(cnt)
}
}
c.SetGridLayout([]int{-1}, nil)

for i, option := range options {
c.IDScope(strconv.Itoa(i), func() {
c.Button(option).On(func() {
*selectedIndex = i
if cnt := c.container(dropdownID, 0); cnt != nil {
// Start the close delay timer (0.1 seconds at TPS rate)
cnt.dropdownCloseDelay = ebiten.TPS() / 10
}
})
})
}
}); err != nil {
return nil, err
}
return nil, nil
})

return c.widget(id, optionAlignCenter, nil, func(bounds image.Rectangle, wasFocused bool) EventHandler {
var e EventHandler

dropdownContainer := c.container(dropdownID, 0)
// Manual "click outside to close" and dropdown toggle, trying to do this in the container.go had lots of issues
if dropdownContainer.open && c.pointing.justPressed() {
clickPos := c.pointingPosition()
clickInButton := clickPos.In(bounds)
clickInDropdown := clickPos.In(dropdownContainer.layout.Bounds)

if !clickInButton && !clickInDropdown {
// Only close immediately if there's no close delay active
if dropdownContainer.dropdownCloseDelay == 0 {
dropdownContainer.open = false
}
}
}

if c.pointing.justPressed() && c.focus == id {
if dropdownContainer.open {
// Close the dropdown immediately and cancel any pending delay
dropdownContainer.open = false
dropdownContainer.dropdownCloseDelay = 0
} else {
wasClosedBefore := !dropdownContainer.open

// Open the dropdown and cancel any pending close delay
dropdownContainer.open = true
dropdownContainer.dropdownCloseDelay = 0

if wasClosedBefore {
dropdownPos := image.Pt(bounds.Min.X, bounds.Max.Y)
buttonWidth := bounds.Dx()
optionHeight := c.style().defaultHeight + c.style().padding + 1
totalHeight := len(options) * optionHeight

maxDropdownHeight := c.style().defaultHeight * 12 // around 10 items visible?
actualHeight := min(totalHeight, maxDropdownHeight)

dropdownContainer.layout.Bounds = image.Rectangle{
Min: dropdownPos,
Max: dropdownPos.Add(image.Pt(buttonWidth, actualHeight)),
}
}
}
}
if last != *selectedIndex {
e = &eventHandler{}
}

return e
}, func(bounds image.Rectangle) {
c.drawWidgetFrame(id, bounds, colorButton, optionAlignCenter)

arrowWidth := bounds.Dy()
textBounds := bounds
textBounds.Max.X -= arrowWidth
c.drawWidgetText(options[*selectedIndex], textBounds, colorText, optionAlignCenter)

arrowBounds := image.Rect(bounds.Max.X-arrowWidth, bounds.Min.Y, bounds.Max.X, bounds.Max.Y)
icon := iconDown
if c.container(dropdownID, 0).open {
icon = iconUp
}
c.drawIcon(icon, arrowBounds, c.style().colors[colorText])
})
}
3 changes: 3 additions & 0 deletions example/gallery/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ type Game struct {
num3_2 float64
num4 float64
num5 int

selectedOption1, selectedOption2 int
dropdownOptions1, dropdownOptions2 []string
}

func NewGame() (*Game, error) {
Expand Down
14 changes: 14 additions & 0 deletions example/gallery/ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,20 @@ func (g *Game) testWindow(ctx *debugui.Context) {
ctx.OpenPopup(popupID)
})
})
g.dropdownOptions1 = []string{"Option 1", "Option 2", "Option 3", "Option 4", "Option 5"}
g.dropdownOptions2 = []string{"Choice A", "Choice B", "Choice C", "Choice D", "Choice E"}
ctx.Header("Dropdown Menu", true, func() {
ctx.SetGridLayout([]int{-1, -1}, nil)
ctx.Text("Select an option:")
ctx.Dropdown(&g.selectedOption1, g.dropdownOptions1).On(func() {
g.writeLog(fmt.Sprintf("Selected option: %s", g.dropdownOptions1[g.selectedOption1]))
})
ctx.Text("Another dropdown:")
ctx.Dropdown(&g.selectedOption2, g.dropdownOptions2).On(func() {
g.writeLog(fmt.Sprintf("Selected another option: %s", g.dropdownOptions2[g.selectedOption2]))
})
})

ctx.Header("Tree and Text", true, func() {
ctx.SetGridLayout([]int{-1, -1}, nil)
ctx.GridCell(func(bounds image.Rectangle) {
Expand Down