Implement menu with dropdown functionality

This commit is contained in:
Benedikt Willi 2025-06-24 12:56:38 +02:00
parent 97b5e3506e
commit 6cb830caa4
5 changed files with 1069 additions and 9 deletions

View file

@ -24,6 +24,7 @@ import (
"github.com/zyedidia/micro/v2/internal/buffer"
"github.com/zyedidia/micro/v2/internal/clipboard"
"github.com/zyedidia/micro/v2/internal/config"
"github.com/zyedidia/micro/v2/internal/display"
"github.com/zyedidia/micro/v2/internal/screen"
"github.com/zyedidia/micro/v2/internal/shell"
"github.com/zyedidia/micro/v2/internal/util"
@ -462,13 +463,35 @@ func DoEvent() {
// Display everything
screen.Screen.Fill(' ', config.DefStyle)
// Check if dropdown menu is open before displaying content
dropdownOpen := action.MenuBar != nil && action.MenuBar.IsOpen()
// Hide cursor initially (will be shown by panes if no dropdown is open)
screen.Screen.HideCursor()
// Display menu bar first (at the top) - but not the dropdown yet
if action.MenuBar != nil {
action.MenuBar.Display()
}
action.Tabs.Display()
for _, ep := range action.MainTab().Panes {
ep.Display()
}
action.MainTab().Display()
action.InfoBar.Display()
// Display dropdown menus LAST so they appear on top of everything
if dropdownOpen {
dropdown := action.MenuBar.GetActiveDropdown()
if dropdown != nil && dropdown.IsVisible() {
dropdown.Display()
// Force cursor to be hidden when dropdown is visible
screen.Screen.HideCursor()
}
}
screen.Screen.Show()
// Check for new events
@ -510,12 +533,67 @@ func DoEvent() {
if event != nil {
_, resize := event.(*tcell.EventResize)
if resize {
// Handle resize for all components
if action.MenuBar != nil {
w, _ := screen.Screen.Size()
action.MenuBar.Resize(w, 1)
}
action.InfoBar.HandleEvent(event)
action.Tabs.HandleEvent(event)
} else if action.InfoBar.HasPrompt {
action.InfoBar.HandleEvent(event)
} else {
action.Tabs.HandleEvent(event)
// Check if menu bar should handle the event first
handled := false
if action.MenuBar != nil {
switch e := event.(type) {
case *tcell.EventMouse:
mx, my := e.Position()
if clickedItem := action.MenuBar.HandleClick(mx, my); clickedItem != nil {
// Menu item was clicked, execute the action
executeMenuAction(clickedItem.Action)
handled = true
}
case *tcell.EventKey:
// Handle keyboard navigation for menus and dropdowns
var selectedItem *display.DropdownItem
// Only handle special keys if menu is open
if action.MenuBar.IsOpen() {
// Menu is open - handle navigation keys
if e.Key() == tcell.KeyEnter || e.Key() == tcell.KeyEscape ||
e.Key() == tcell.KeyUp || e.Key() == tcell.KeyDown ||
e.Key() == tcell.KeyLeft || e.Key() == tcell.KeyRight {
selectedItem = action.MenuBar.HandleKeyNavigation(e.Rune(), int(e.Key()))
handled = true
} else {
// Menu is open, check for dropdown item hotkeys
selectedItem = action.MenuBar.HandleKeyNavigation(e.Rune(), int(e.Key()))
if selectedItem != nil {
handled = true
}
}
} else if e.Modifiers()&tcell.ModAlt != 0 {
// Menu is closed - only handle Alt+key combinations to open menus
selectedItem = action.MenuBar.HandleKeyNavigation(e.Rune(), int(e.Key()))
if selectedItem != nil || action.MenuBar.IsOpen() {
handled = true
}
}
// Execute action if a menu item was selected
if selectedItem != nil {
// Execute the selected action
executeMenuAction(selectedItem.Action)
handled = true
}
}
}
// If menu didn't handle it, pass to tabs
if !handled {
action.Tabs.HandleEvent(event)
}
}
}
@ -524,3 +602,66 @@ func DoEvent() {
screen.TermMessage(err)
}
}
// executeMenuAction executes the specified action from a menu selection
func executeMenuAction(actionName string) {
// Get the current buffer pane to perform actions on
pane := action.MainTab().CurPane()
if pane == nil {
return
}
// Execute the appropriate action based on the action name
switch actionName {
case "NewTab":
pane.NewTabCmd([]string{})
case "Open":
pane.OpenFile()
case "Save":
pane.Save()
case "SaveAs":
pane.SaveAs()
case "Quit":
pane.Quit()
case "Undo":
pane.Undo()
case "Redo":
pane.Redo()
case "Cut":
pane.Cut()
case "Copy":
pane.Copy()
case "Paste":
pane.Paste()
case "HSplit":
pane.HSplitAction()
case "VSplit":
pane.VSplitAction()
case "ToggleRuler":
pane.ToggleRuler()
case "Find":
pane.Find()
case "FindNext":
pane.FindNext()
case "FindPrevious":
pane.FindPrevious()
case "Replace":
pane.ReplaceCmd([]string{})
case "CommandMode":
pane.CommandMode()
case "PluginInstall":
// Open command mode with plugin install command
pane.CommandMode()
// TODO: Pre-fill with "plugin install " if possible
case "ToggleHelp":
pane.ToggleHelp()
case "ShowKey":
pane.ToggleKeyMenu()
case "ShowAbout":
// Display about information
screen.TermMessage("Micro " + util.Version + " - " + util.CommitHash)
default:
// If action not recognized, try to execute it as a command
screen.TermMessage("Unknown action: " + actionName)
}
}

View file

@ -1,10 +1,17 @@
package action
import "github.com/zyedidia/micro/v2/internal/buffer"
import (
"github.com/zyedidia/micro/v2/internal/buffer"
"github.com/zyedidia/micro/v2/internal/display"
"github.com/zyedidia/micro/v2/internal/screen"
)
// InfoBar is the global info bar.
var InfoBar *InfoPane
// MenuBar is the global menu bar.
var MenuBar *display.MenuWindow
// LogBufPane is a global log buffer.
var LogBufPane *BufPane
@ -12,6 +19,10 @@ var LogBufPane *BufPane
func InitGlobals() {
InfoBar = NewInfoBar()
buffer.LogBuf = buffer.NewBufferFromString("", "Log", buffer.BTLog)
// Initialize menu bar at the top
w, _ := screen.Screen.Size()
MenuBar = display.NewMenuWindow(0, 0, w, 1)
}
// GetInfoBar returns the infobar pane

View file

@ -24,16 +24,21 @@ type TabList struct {
func NewTabList(bufs []*buffer.Buffer) *TabList {
w, h := screen.Screen.Size()
iOffset := config.GetInfoBarOffset()
menuBarHeight := 1 // Reserve space for menu bar
tl := new(TabList)
tl.List = make([]*Tab, len(bufs))
if len(bufs) > 1 {
// Multiple tabs: content starts below menu bar + tab bar
for i, b := range bufs {
tl.List[i] = NewTabFromBuffer(0, 1, w, h-1-iOffset, b)
tl.List[i] = NewTabFromBuffer(0, menuBarHeight+1, w, h-1-iOffset-menuBarHeight, b)
}
} else {
tl.List[0] = NewTabFromBuffer(0, 0, w, h-iOffset, bufs[0])
// Single tab: content starts directly below menu bar
tl.List[0] = NewTabFromBuffer(0, menuBarHeight, w, h-iOffset-menuBarHeight, bufs[0])
}
tl.TabWindow = display.NewTabWindow(w, 0)
// TabWindow positioned below menu bar
tl.TabWindow = display.NewTabWindow(w, menuBarHeight)
tl.Names = make([]string, len(bufs))
return tl
@ -82,16 +87,28 @@ func (t *TabList) RemoveTab(id uint64) {
func (t *TabList) Resize() {
w, h := screen.Screen.Size()
iOffset := config.GetInfoBarOffset()
// Reserve space for menu bar at the top
menuBarHeight := 1
if MenuBar != nil {
MenuBar.Resize(w, menuBarHeight)
}
// InfoBar remains at the bottom
InfoBar.Resize(w, h-1)
if len(t.List) > 1 {
// Tab bar is below menu bar
tabBarY := menuBarHeight
for _, p := range t.List {
p.Y = 1
p.Node.Resize(w, h-1-iOffset)
p.Y = tabBarY + 1 // Content starts below both menu and tab bars
p.Node.Resize(w, h-1-iOffset-menuBarHeight)
p.Resize()
}
} else if len(t.List) == 1 {
t.List[0].Y = 0
t.List[0].Node.Resize(w, h-iOffset)
// Single tab: content starts directly below menu bar
t.List[0].Y = menuBarHeight
t.List[0].Node.Resize(w, h-iOffset-menuBarHeight)
t.List[0].Resize()
}
t.TabWindow.Resize(w, h)

View file

@ -0,0 +1,430 @@
package display
import (
runewidth "github.com/mattn/go-runewidth"
"github.com/zyedidia/micro/v2/internal/config"
"github.com/zyedidia/micro/v2/internal/screen"
"github.com/zyedidia/micro/v2/internal/util"
)
// DropdownItem represents a single item in a dropdown menu
type DropdownItem struct {
Text string
Action string
Hotkey rune
Enabled bool
Separator bool // True for separator lines
}
// DropdownMenu represents a dropdown menu that appears below menu items
type DropdownMenu struct {
Items []DropdownItem
X int
Y int
Width int
Height int
Active int // Currently highlighted item (-1 for none)
Visible bool // Whether the dropdown is currently shown
}
// NewDropdownMenu creates a new dropdown menu
func NewDropdownMenu() *DropdownMenu {
return &DropdownMenu{
Items: []DropdownItem{},
Active: -1,
Visible: false,
}
}
// SetItems sets the items for this dropdown menu
func (d *DropdownMenu) SetItems(items []DropdownItem) {
d.Items = items
d.calculateSize()
}
// calculateSize determines the width and height needed for the dropdown
func (d *DropdownMenu) calculateSize() {
d.Width = 0
d.Height = len(d.Items) + 2 // +2 for top and bottom borders
// Find the widest item
for _, item := range d.Items {
if item.Separator {
continue
}
itemWidth := util.StringWidth([]byte(item.Text), util.CharacterCountInString(item.Text), 1)
if item.Hotkey != 0 {
itemWidth += 4 // Space for " (X)" hotkey display
}
if itemWidth > d.Width {
d.Width = itemWidth
}
}
// Add padding and border
d.Width += 4 // 2 for borders + 2 for padding
if d.Width < 8 {
d.Width = 8 // Minimum width
}
}
// Show displays the dropdown at the specified position
func (d *DropdownMenu) Show(x, y int) {
d.X = x
d.Y = y
d.Visible = true
// Set the first selectable item as active
d.Active = -1
for i := 0; i < len(d.Items); i++ {
if d.Items[i].Enabled && !d.Items[i].Separator {
d.Active = i
break
}
}
}
// Hide hides the dropdown
func (d *DropdownMenu) Hide() {
d.Visible = false
d.Active = -1
}
// IsVisible returns whether the dropdown is currently visible
func (d *DropdownMenu) IsVisible() bool {
return d.Visible
}
// Display renders the dropdown menu
func (d *DropdownMenu) Display() {
if !d.Visible || d.Height == 0 {
return
}
// Get terminal size to ensure we don't draw outside bounds
termWidth, termHeight := screen.Screen.Size()
// Adjust position if dropdown would go off screen
adjustedX := d.X
adjustedY := d.Y
if adjustedX+d.Width > termWidth {
adjustedX = termWidth - d.Width
if adjustedX < 0 {
adjustedX = 0
}
}
if adjustedY+d.Height > termHeight {
adjustedY = termHeight - d.Height
if adjustedY < 0 {
adjustedY = 0
}
}
// Draw dropdown background and border with proper backdrop
// Use normal style for dropdown, reverse for highlighting
dropdownStyle := config.DefStyle
borderStyle := config.DefStyle
shadowStyle := config.DefStyle.Dim(true) // For drop shadow effect
// Draw shadow effect first (offset by 1 pixel)
for row := 1; row <= d.Height; row++ {
for col := 1; col <= d.Width; col++ {
x := adjustedX + col
y := adjustedY + row
if x < termWidth && y < termHeight {
screen.SetContent(x, y, ' ', nil, shadowStyle)
}
}
}
for row := 0; row < d.Height; row++ {
y := adjustedY + row
if y >= termHeight {
break
}
for col := 0; col < d.Width; col++ {
x := adjustedX + col
if x >= termWidth {
break
}
// Draw border
if col == 0 || col == d.Width-1 {
if row == 0 {
if col == 0 {
screen.SetContent(x, y, '┌', nil, borderStyle)
} else {
screen.SetContent(x, y, '┐', nil, borderStyle)
}
} else if row == d.Height-1 {
if col == 0 {
screen.SetContent(x, y, '└', nil, borderStyle)
} else {
screen.SetContent(x, y, '┘', nil, borderStyle)
}
} else {
screen.SetContent(x, y, '│', nil, borderStyle)
}
} else if row == 0 || row == d.Height-1 {
screen.SetContent(x, y, '─', nil, borderStyle)
} else {
screen.SetContent(x, y, ' ', nil, dropdownStyle)
}
}
}
// Draw menu items
itemY := 0
for i, item := range d.Items {
if itemY >= d.Height-2 { // Account for top and bottom borders
break
}
y := adjustedY + 1 + itemY // +1 for top border
if y >= termHeight {
break
}
if item.Separator {
// Draw separator line
for x := adjustedX + 1; x < adjustedX+d.Width-1; x++ {
if x < termWidth {
screen.SetContent(x, y, '─', nil, borderStyle)
}
}
} else {
// Draw menu item
itemStyle := dropdownStyle
if i == d.Active {
// Highlight active item
itemStyle = itemStyle.Reverse(true)
}
if !item.Enabled {
// Dim disabled items
itemStyle = itemStyle.Dim(true)
}
// Clear the line first
for x := adjustedX + 1; x < adjustedX+d.Width-1; x++ {
if x < termWidth {
screen.SetContent(x, y, ' ', nil, itemStyle)
}
}
// Draw item text
x := adjustedX + 2 // +2 for border and padding
for _, r := range item.Text {
if x >= adjustedX+d.Width-2 || x >= termWidth {
break
}
screen.SetContent(x, y, r, nil, itemStyle)
x += runewidth.RuneWidth(r)
}
// Draw hotkey if present
if item.Hotkey != 0 && x < adjustedX+d.Width-4 {
hotkeyText := " (" + string(item.Hotkey) + ")"
for _, r := range hotkeyText {
if x >= adjustedX+d.Width-2 || x >= termWidth {
break
}
screen.SetContent(x, y, r, nil, itemStyle.Dim(true))
x += runewidth.RuneWidth(r)
}
}
}
itemY++
}
}
// HandleClick handles mouse clicks on the dropdown
func (d *DropdownMenu) HandleClick(x, y int) *DropdownItem {
if !d.Visible {
return nil
}
// Check if click is inside dropdown bounds
if x < d.X || x >= d.X+d.Width || y < d.Y || y >= d.Y+d.Height {
// Click outside dropdown - hide it
d.Hide()
return nil
}
// Check if click is on border
if x == d.X || x == d.X+d.Width-1 || y == d.Y || y == d.Y+d.Height-1 {
return nil
}
// Calculate which item was clicked
itemIndex := y - d.Y - 1 // -1 for top border
if itemIndex >= 0 && itemIndex < len(d.Items) {
item := &d.Items[itemIndex]
if !item.Separator && item.Enabled {
d.Hide()
return item
}
}
return nil
}
// HandleKey handles keyboard navigation in the dropdown
func (d *DropdownMenu) HandleKey(key rune) *DropdownItem {
if !d.Visible {
return nil
}
// Check for hotkey matches
for _, item := range d.Items {
if !item.Separator && item.Enabled {
if key == item.Hotkey || (key >= 'A' && key <= 'Z' && key-'A'+'a' == item.Hotkey) {
d.Hide()
return &item
}
}
}
return nil
}
// NavigateUp moves selection up in the dropdown
func (d *DropdownMenu) NavigateUp() {
if !d.Visible {
return
}
for i := d.Active - 1; i >= 0; i-- {
if !d.Items[i].Separator && d.Items[i].Enabled {
d.Active = i
return
}
}
// Wrap to bottom
for i := len(d.Items) - 1; i > d.Active; i-- {
if !d.Items[i].Separator && d.Items[i].Enabled {
d.Active = i
return
}
}
}
// NavigateDown moves selection down in the dropdown
func (d *DropdownMenu) NavigateDown() {
if !d.Visible {
return
}
for i := d.Active + 1; i < len(d.Items); i++ {
if !d.Items[i].Separator && d.Items[i].Enabled {
d.Active = i
return
}
}
// Wrap to top
for i := 0; i < d.Active; i++ {
if !d.Items[i].Separator && d.Items[i].Enabled {
d.Active = i
return
}
}
}
// SelectActive returns the currently active item and hides the dropdown
func (d *DropdownMenu) SelectActive() *DropdownItem {
if !d.Visible || d.Active < 0 || d.Active >= len(d.Items) {
return nil
}
item := &d.Items[d.Active]
if !item.Separator && item.Enabled {
d.Hide()
return item
}
return nil
}
// GetActiveItem returns the currently active item, or nil if none
func (d *DropdownMenu) GetActiveItem() *DropdownItem {
if d.Active >= 0 && d.Active < len(d.Items) {
return &d.Items[d.Active]
}
return nil
}
// MoveUp moves selection up to previous selectable item
func (d *DropdownMenu) MoveUp() {
if d.Active < 0 {
// No item selected, select the last selectable item
for i := len(d.Items) - 1; i >= 0; i-- {
if d.Items[i].Enabled && !d.Items[i].Separator {
d.Active = i
return
}
}
} else if d.Active == 0 {
// At first item, wrap to last selectable item
for i := len(d.Items) - 1; i >= 0; i-- {
if d.Items[i].Enabled && !d.Items[i].Separator {
d.Active = i
return
}
}
} else {
// Move to previous selectable item
for i := d.Active - 1; i >= 0; i-- {
if d.Items[i].Enabled && !d.Items[i].Separator {
d.Active = i
return
}
}
// If no previous selectable item found, wrap to last
for i := len(d.Items) - 1; i >= 0; i-- {
if d.Items[i].Enabled && !d.Items[i].Separator {
d.Active = i
return
}
}
}
}
// MoveDown moves selection down to next selectable item
func (d *DropdownMenu) MoveDown() {
if d.Active < 0 {
// No item selected, select the first selectable item
for i := 0; i < len(d.Items); i++ {
if d.Items[i].Enabled && !d.Items[i].Separator {
d.Active = i
return
}
}
} else if d.Active >= len(d.Items)-1 {
// At last item, wrap to first selectable item
for i := 0; i < len(d.Items); i++ {
if d.Items[i].Enabled && !d.Items[i].Separator {
d.Active = i
return
}
}
} else {
// Move to next selectable item
for i := d.Active + 1; i < len(d.Items); i++ {
if d.Items[i].Enabled && !d.Items[i].Separator {
d.Active = i
return
}
}
// If no next selectable item found, wrap to first
for i := 0; i < len(d.Items); i++ {
if d.Items[i].Enabled && !d.Items[i].Separator {
d.Active = i
return
}
}
}
}

View file

@ -0,0 +1,461 @@
package display
import (
runewidth "github.com/mattn/go-runewidth"
"github.com/micro-editor/tcell/v2"
"github.com/zyedidia/micro/v2/internal/config"
"github.com/zyedidia/micro/v2/internal/screen"
"github.com/zyedidia/micro/v2/internal/util"
)
// MenuItem represents a single menu item
type MenuItem struct {
Name string
Action string
Hotkey rune
Enabled bool
}
// MenuWindow displays a horizontal menu bar at the top of the screen
type MenuWindow struct {
MenuItems []MenuItem
Active int
Width int
Height int
Y int
open bool // whether a menu is currently open
dropdownMenus map[string]*DropdownMenu // dropdown menus for each menu item
}
// NewMenuWindow creates a new MenuWindow
func NewMenuWindow(x, y, w, h int) *MenuWindow {
mw := new(MenuWindow)
mw.MenuItems = []MenuItem{
{Name: "File", Action: "file", Hotkey: 'i', Enabled: true}, // Alt+i (was F)
{Name: "Edit", Action: "edit", Hotkey: 'd', Enabled: true}, // Alt+d (was E)
{Name: "View", Action: "view", Hotkey: 'w', Enabled: true}, // Alt+w (was V)
{Name: "Search", Action: "search", Hotkey: 's', Enabled: true}, // Alt+s (was S)
{Name: "Tools", Action: "tools", Hotkey: 't', Enabled: true}, // Alt+t (was T)
{Name: "Help", Action: "help", Hotkey: 'h', Enabled: true}, // Alt+h (was H)
}
mw.Active = -1 // No active menu by default
mw.Width = w
mw.Height = h
mw.Y = y
mw.open = false // Menu is closed by default
mw.dropdownMenus = make(map[string]*DropdownMenu)
// Initialize dropdown menus
mw.initializeDropdownMenus()
return mw
}
// initializeDropdownMenus sets up the dropdown menus for each main menu item
func (w *MenuWindow) initializeDropdownMenus() {
// File menu
fileMenu := NewDropdownMenu()
fileMenu.SetItems([]DropdownItem{
{Text: "New", Action: "NewTab", Hotkey: 'N', Enabled: true},
{Text: "Open", Action: "Open", Hotkey: 'O', Enabled: true},
{Separator: true},
{Text: "Save", Action: "Save", Hotkey: 'S', Enabled: true},
{Text: "Save As", Action: "SaveAs", Hotkey: 'A', Enabled: true},
{Separator: true},
{Text: "Quit", Action: "Quit", Hotkey: 'Q', Enabled: true},
})
w.dropdownMenus["file"] = fileMenu
// Edit menu
editMenu := NewDropdownMenu()
editMenu.SetItems([]DropdownItem{
{Text: "Undo", Action: "Undo", Hotkey: 'U', Enabled: true},
{Text: "Redo", Action: "Redo", Hotkey: 'R', Enabled: true},
{Separator: true},
{Text: "Cut", Action: "Cut", Hotkey: 'X', Enabled: true},
{Text: "Copy", Action: "Copy", Hotkey: 'C', Enabled: true},
{Text: "Paste", Action: "Paste", Hotkey: 'V', Enabled: true},
})
w.dropdownMenus["edit"] = editMenu
// View menu
viewMenu := NewDropdownMenu()
viewMenu.SetItems([]DropdownItem{
{Text: "Split Horizontal", Action: "HSplit", Hotkey: 'H', Enabled: true},
{Text: "Split Vertical", Action: "VSplit", Hotkey: 'V', Enabled: true},
{Separator: true},
{Text: "Toggle Line Numbers", Action: "ToggleRuler", Hotkey: 'L', Enabled: true},
})
w.dropdownMenus["view"] = viewMenu
// Search menu
searchMenu := NewDropdownMenu()
searchMenu.SetItems([]DropdownItem{
{Text: "Find", Action: "Find", Hotkey: 'F', Enabled: true},
{Text: "Find Next", Action: "FindNext", Hotkey: 'N', Enabled: true},
{Text: "Find Previous", Action: "FindPrevious", Hotkey: 'P', Enabled: true},
{Separator: true},
{Text: "Replace", Action: "Replace", Hotkey: 'R', Enabled: true},
})
w.dropdownMenus["search"] = searchMenu
// Tools menu
toolsMenu := NewDropdownMenu()
toolsMenu.SetItems([]DropdownItem{
{Text: "Command Palette", Action: "CommandMode", Hotkey: 'C', Enabled: true},
{Text: "Plugin Manager", Action: "PluginInstall", Hotkey: 'P', Enabled: true},
})
w.dropdownMenus["tools"] = toolsMenu
// Help menu
helpMenu := NewDropdownMenu()
helpMenu.SetItems([]DropdownItem{
{Text: "Help", Action: "ToggleHelp", Hotkey: 'H', Enabled: true},
{Text: "Key Bindings", Action: "ShowKey", Hotkey: 'K', Enabled: true},
{Separator: true},
{Text: "About", Action: "ShowAbout", Hotkey: 'A', Enabled: true},
})
w.dropdownMenus["help"] = helpMenu
}
// Resize adjusts the menu window size
func (w *MenuWindow) Resize(width, height int) {
w.Width = width
}
// SetActive sets the active menu item
func (w *MenuWindow) SetActive(index int) {
if index >= 0 && index < len(w.MenuItems) {
w.Active = index
} else {
w.Active = -1
}
}
// GetActive returns the currently active menu item
func (w *MenuWindow) GetActive() int {
return w.Active
}
// IsOpen returns whether a menu is currently open
func (w *MenuWindow) IsOpen() bool {
return w.open
}
// SetOpen sets the menu open state
func (w *MenuWindow) SetOpen(open bool) {
w.open = open
// Show/hide the appropriate dropdown menu
if open && w.Active >= 0 && w.Active < len(w.MenuItems) {
activeItem := w.MenuItems[w.Active]
if dropdown, exists := w.dropdownMenus[activeItem.Action]; exists {
// Calculate dropdown position
dropdownX := w.getMenuItemX(w.Active)
dropdownY := w.Y + 1 // Below the menu bar
dropdown.Show(dropdownX, dropdownY)
}
} else {
// Hide all dropdown menus
for _, dropdown := range w.dropdownMenus {
dropdown.Hide()
}
}
}
// getMenuItemX calculates the X position of a menu item
func (w *MenuWindow) getMenuItemX(index int) int {
x := 0
for i := 0; i < index && i < len(w.MenuItems); i++ {
item := w.MenuItems[i]
if !item.Enabled {
continue
}
itemWidth := util.StringWidth([]byte(item.Name), util.CharacterCountInString(item.Name), 1)
x += itemWidth + 2 // +2 for padding
}
return x
}
// Display renders the menu bar
func (w *MenuWindow) Display() {
if w.Height <= 0 {
return
}
// Clear the menu bar area
for x := 0; x < w.Width; x++ {
screen.SetContent(x, w.Y, ' ', nil, config.DefStyle)
}
x := 0
for i, item := range w.MenuItems {
if !item.Enabled {
continue
}
// Calculate item display text
displayText := item.Name
itemWidth := util.StringWidth([]byte(displayText), util.CharacterCountInString(displayText), 1)
// Add padding
padding := 2
totalWidth := itemWidth + padding
// Check if we have space for this item
if x+totalWidth > w.Width {
break
}
// Determine style based on active state
style := config.DefStyle
if i == w.Active {
// Highlight active menu item
style = style.Reverse(true)
}
// Add left padding
screen.SetContent(x, w.Y, ' ', nil, style)
x++
// Render the menu item text with hotkey highlighting
for j, r := range displayText {
charStyle := style
// Highlight the hotkey character
if r == item.Hotkey || (r >= 'A' && r <= 'Z' && r-'A'+'a' == item.Hotkey) {
charStyle = charStyle.Underline(true)
}
screen.SetContent(x, w.Y, r, nil, charStyle)
x += runewidth.RuneWidth(r)
// Handle zero-width characters
if runewidth.RuneWidth(r) == 0 && j > 0 {
x = x - 1
}
}
// Add right padding
screen.SetContent(x, w.Y, ' ', nil, style)
x++
}
// Fill remaining space with default style
for x < w.Width {
screen.SetContent(x, w.Y, ' ', nil, config.DefStyle)
x++
}
// Note: Dropdown menus are now displayed separately in the main event loop
// to ensure they appear on top of all other content
}
// HandleClick handles mouse clicks on the menu bar and dropdowns
func (w *MenuWindow) HandleClick(x, y int) *DropdownItem {
// First check if click is on an open dropdown
if w.open && w.Active >= 0 && w.Active < len(w.MenuItems) {
activeItem := w.MenuItems[w.Active]
if dropdown, exists := w.dropdownMenus[activeItem.Action]; exists && dropdown.IsVisible() {
if clickedItem := dropdown.HandleClick(x, y); clickedItem != nil {
// A dropdown item was clicked - return it for execution
w.SetActive(-1)
w.SetOpen(false)
return clickedItem
}
// Click might have closed the dropdown, check if we should handle menu bar click
if !dropdown.IsVisible() {
w.SetActive(-1)
w.SetOpen(false)
}
}
}
// Check if click is on menu bar
if y != w.Y {
// Click outside menu bar and dropdown - close any open menu
if w.open {
w.SetActive(-1)
w.SetOpen(false)
}
return nil
}
// Calculate which menu item was clicked
currentX := 0
for i, item := range w.MenuItems {
if !item.Enabled {
continue
}
itemWidth := util.StringWidth([]byte(item.Name), util.CharacterCountInString(item.Name), 1) + 2 // +2 for padding
if x >= currentX && x < currentX+itemWidth {
if w.Active == i && w.open {
// Close if clicking on already open menu
w.SetActive(-1)
w.SetOpen(false)
} else {
// Activate and open menu
w.SetActive(i)
w.SetOpen(true)
}
return nil
}
currentX += itemWidth
}
// Click outside menu items - close any open menu
w.SetActive(-1)
w.SetOpen(false)
return nil
}
// HandleKey handles keyboard input for menu navigation
func (w *MenuWindow) HandleKey(key rune) bool {
// Check for hotkey matches
for i, item := range w.MenuItems {
if !item.Enabled {
continue
}
if key == item.Hotkey || (key >= 'A' && key <= 'Z' && key-'A'+'a' == item.Hotkey) {
w.SetActive(i)
w.SetOpen(true)
return true
}
}
return false
}
// HandleKeyNavigation handles keyboard navigation for menu and dropdown
func (w *MenuWindow) HandleKeyNavigation(key rune, keyCode int) *DropdownItem {
// If no menu is active, check for Alt+hotkey combinations
if !w.open || w.Active < 0 {
// Check for hotkey matches to open menus
for i, item := range w.MenuItems {
if !item.Enabled {
continue
}
if key == item.Hotkey || (key >= 'A' && key <= 'Z' && key-'A'+'a' == item.Hotkey) {
w.SetActive(i)
w.SetOpen(true)
return nil
}
}
return nil
}
// If a menu is open, handle dropdown navigation
if w.Active >= 0 && w.Active < len(w.MenuItems) {
activeItem := w.MenuItems[w.Active]
if dropdown, exists := w.dropdownMenus[activeItem.Action]; exists && dropdown.IsVisible() {
// Use tcell key constants for proper key detection
switch keyCode {
case int(tcell.KeyEnter):
selectedItem := dropdown.GetActiveItem()
if selectedItem != nil && selectedItem.Enabled && !selectedItem.Separator {
w.SetActive(-1)
w.SetOpen(false)
return selectedItem
}
case int(tcell.KeyEscape):
w.SetActive(-1)
w.SetOpen(false)
return nil
case int(tcell.KeyUp):
dropdown.MoveUp()
return nil
case int(tcell.KeyDown):
dropdown.MoveDown()
return nil
case int(tcell.KeyLeft):
w.navigateToPreviousMenu()
return nil
case int(tcell.KeyRight):
w.navigateToNextMenu()
return nil
default:
// Check for dropdown item hotkeys
for _, item := range dropdown.Items {
if !item.Separator && item.Enabled {
if key == item.Hotkey || (key >= 'A' && key <= 'Z' && key-'A'+'a' == item.Hotkey) {
w.SetActive(-1)
w.SetOpen(false)
return &item
}
}
}
}
}
}
return nil
}
// navigateToPreviousMenu moves to the previous menu item
func (w *MenuWindow) navigateToPreviousMenu() {
if w.Active <= 0 {
// Wrap to last menu
for i := len(w.MenuItems) - 1; i >= 0; i-- {
if w.MenuItems[i].Enabled {
w.SetActive(i)
w.SetOpen(true)
return
}
}
} else {
// Move to previous enabled menu
for i := w.Active - 1; i >= 0; i-- {
if w.MenuItems[i].Enabled {
w.SetActive(i)
w.SetOpen(true)
return
}
}
}
}
// navigateToNextMenu moves to the next menu item
func (w *MenuWindow) navigateToNextMenu() {
if w.Active >= len(w.MenuItems)-1 {
// Wrap to first menu
for i := 0; i < len(w.MenuItems); i++ {
if w.MenuItems[i].Enabled {
w.SetActive(i)
w.SetOpen(true)
return
}
}
} else {
// Move to next enabled menu
for i := w.Active + 1; i < len(w.MenuItems); i++ {
if w.MenuItems[i].Enabled {
w.SetActive(i)
w.SetOpen(true)
return
}
}
}
}
// GetMenuAction returns the action for the currently active menu
func (w *MenuWindow) GetMenuAction() string {
if w.Active >= 0 && w.Active < len(w.MenuItems) {
return w.MenuItems[w.Active].Action
}
return ""
}
// GetActiveDropdown returns the currently active dropdown menu
func (w *MenuWindow) GetActiveDropdown() *DropdownMenu {
if w.open && w.Active >= 0 && w.Active < len(w.MenuItems) {
activeItem := w.MenuItems[w.Active]
if dropdown, exists := w.dropdownMenus[activeItem.Action]; exists {
return dropdown
}
}
return nil
}