Push: Initial push
This commit is contained in:
@@ -0,0 +1,211 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user