// Package components provides custom tview primitives for the Nebara OS TUI. // Each component is self-contained and themed using the centralized design system. package components import ( "github.com/gdamore/tcell/v2" "github.com/nebara/nebara-os/internal/model" "github.com/nebara/nebara-os/internal/ui/theme" "github.com/rivo/tview" ) // MenuWidget is a custom tview.Primitive that renders the navigation menu // with full-width row highlighting, a single-cell border, and inner padding. // It implements the exact Figma selection design: full-width BBBBBB background. type MenuWidget struct { *tview.Box items []model.MenuItem selectedIdx int innerPad int // horizontal cells of padding inside the border // onSelectionChanged is called whenever the selected index changes. onSelectionChanged func(idx int) // onEnter is called when the user presses Enter on an item. onEnter func(idx int) // onBack is called when the user presses Escape. onBack func() } // NewMenuWidget creates a MenuWidget with the given items and padding. func NewMenuWidget(items []model.MenuItem, innerPad int) *MenuWidget { m := &MenuWidget{ Box: tview.NewBox(), items: items, innerPad: innerPad, } m.Box.SetBackgroundColor(theme.ColorBackground) m.Box.SetBorderColor(theme.ColorBorder) m.Box.SetBorder(true) return m } // SetInnerPad updates the horizontal padding inside the border. func (m *MenuWidget) SetInnerPad(pad int) { m.innerPad = pad } // SetItems replaces the current menu items. func (m *MenuWidget) SetItems(items []model.MenuItem) { m.items = items if m.selectedIdx >= len(items) { m.selectedIdx = 0 } } // SetSelectedIndex sets the highlighted row. func (m *MenuWidget) SetSelectedIndex(idx int) { if idx >= 0 && idx < len(m.items) { m.selectedIdx = idx } } // SelectedIndex returns the current highlighted row index. func (m *MenuWidget) SelectedIndex() int { return m.selectedIdx } // SetOnSelectionChanged registers a callback invoked on selection movement. func (m *MenuWidget) SetOnSelectionChanged(fn func(int)) { m.onSelectionChanged = fn } // SetOnEnter registers a callback invoked when Enter is pressed. func (m *MenuWidget) SetOnEnter(fn func(int)) { m.onEnter = fn } // SetOnBack registers a callback invoked when Escape is pressed. func (m *MenuWidget) SetOnBack(fn func()) { m.onBack = fn } // Draw renders the menu widget. // The border is drawn by tview.Box; we draw the items in the inner area. func (m *MenuWidget) Draw(screen tcell.Screen) { // Draw the box border first. m.Box.Draw(screen) // Get the inner area (inside the border). x, y, w, h := m.Box.GetInnerRect() if w <= 0 || h <= 0 { return } // Render each item. Each item occupies exactly one row with one blank row // between items for visual breathing room, matching the Figma layout. // We use a 1-cell vertical inset inside the border (top and bottom). innerY := y + 1 // 1-cell top inset innerH := h - 2 // reserve 1-cell bottom inset _ = innerH for i, item := range m.items { row := innerY + i*2 // 2 rows per item: text + blank separator if row >= y+h-1 { break // do not overflow the border bottom } isSelected := i == m.selectedIdx var fg, bg tcell.Color var bold bool if isSelected { fg = theme.ColorSelectionFg bg = theme.ColorSelectionBg bold = false } else { fg = theme.ColorPrimary bg = theme.ColorBackground bold = false } style := tcell.StyleDefault.Background(bg).Foreground(fg) if bold { style = style.Bold(true) } // Build the label: ">" prefix for selected, " " otherwise. prefix := " " if isSelected { prefix = "> " } label := prefix + item.Label // Fill the entire row width with the selection background. // This gives the full-width highlight shown in the mockup. fillRow(screen, x, row, w, style) // Draw the label text (with left padding inside the border). textX := x + m.innerPad drawText(screen, textX, row, label, style) } } // InputHandler handles keyboard events for the menu widget. func (m *MenuWidget) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { return m.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { switch event.Key() { case tcell.KeyUp: if m.selectedIdx > 0 { m.selectedIdx-- if m.onSelectionChanged != nil { m.onSelectionChanged(m.selectedIdx) } } case tcell.KeyDown: if m.selectedIdx < len(m.items)-1 { m.selectedIdx++ if m.onSelectionChanged != nil { m.onSelectionChanged(m.selectedIdx) } } case tcell.KeyHome: if m.selectedIdx != 0 { m.selectedIdx = 0 if m.onSelectionChanged != nil { m.onSelectionChanged(m.selectedIdx) } } case tcell.KeyEnd: last := len(m.items) - 1 if m.selectedIdx != last { m.selectedIdx = last if m.onSelectionChanged != nil { m.onSelectionChanged(m.selectedIdx) } } case tcell.KeyEnter: if m.onEnter != nil { m.onEnter(m.selectedIdx) } case tcell.KeyEscape: if m.onBack != nil { m.onBack() } } }) } // HasFocus is required to satisfy tview.Primitive. func (m *MenuWidget) HasFocus() bool { return m.Box.HasFocus() } // Focus is required to satisfy tview.Primitive. func (m *MenuWidget) Focus(delegate func(p tview.Primitive)) { m.Box.Focus(delegate) } // fillRow paints an entire horizontal row with the given style. func fillRow(screen tcell.Screen, x, y, width int, style tcell.Style) { for col := x; col < x+width; col++ { screen.SetContent(col, y, ' ', nil, style) } } // drawText writes a string starting at (x, y) using the given style. func drawText(screen tcell.Screen, x, y int, text string, style tcell.Style) { for i, ch := range text { screen.SetContent(x+i, y, ch, nil, style) } }