Files

212 lines
5.7 KiB
Go

// 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)
}
}