diff --git a/cmd/micro/micro.go b/cmd/micro/micro.go index e43073b4..479d0a0d 100644 --- a/cmd/micro/micro.go +++ b/cmd/micro/micro.go @@ -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) + } +} diff --git a/internal/action/globals.go b/internal/action/globals.go index c4869c11..5607023d 100644 --- a/internal/action/globals.go +++ b/internal/action/globals.go @@ -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 diff --git a/internal/action/tab.go b/internal/action/tab.go index 076df5f8..9925aa09 100644 --- a/internal/action/tab.go +++ b/internal/action/tab.go @@ -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) diff --git a/internal/display/dropdown.go b/internal/display/dropdown.go new file mode 100644 index 00000000..45c7e070 --- /dev/null +++ b/internal/display/dropdown.go @@ -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 + } + } + } +} diff --git a/internal/display/menuwindow.go b/internal/display/menuwindow.go new file mode 100644 index 00000000..e7eaefd4 --- /dev/null +++ b/internal/display/menuwindow.go @@ -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 +}