212 lines
5.7 KiB
Go
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)
|
|
}
|
|
}
|