Del: Update
This commit is contained in:
@@ -1,326 +0,0 @@
|
|||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/gdamore/tcell/v2"
|
|
||||||
"github.com/nebara/nebara-os/internal/ui/components"
|
|
||||||
"github.com/nebara/nebara-os/internal/ui/layout"
|
|
||||||
"github.com/nebara/nebara-os/internal/ui/theme"
|
|
||||||
"github.com/rivo/tview"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Application is the root Nebara OS TUI application object.
|
|
||||||
// It owns the tview application, all UI components, and the application state.
|
|
||||||
type Application struct {
|
|
||||||
tviewApp *tview.Application
|
|
||||||
state *State
|
|
||||||
|
|
||||||
// tview layout primitives
|
|
||||||
pages *tview.Pages
|
|
||||||
rootFlex *tview.Flex // full-screen outer flex
|
|
||||||
contentFlex *tview.Flex // inner vertical flex with margins applied
|
|
||||||
|
|
||||||
// Named UI components
|
|
||||||
header *components.HeaderWidget
|
|
||||||
appInfo *components.ApplianceInfoWidget
|
|
||||||
msgWidget *components.MessageWidget
|
|
||||||
menuWidget *components.MenuWidget
|
|
||||||
description *components.DescriptionWidget
|
|
||||||
footer *components.FooterWidget
|
|
||||||
|
|
||||||
// Current layout constraints
|
|
||||||
constraints layout.Constraints
|
|
||||||
|
|
||||||
// Minimum-size overlay: when true we only show the size warning.
|
|
||||||
tooSmall bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewApplication constructs the complete TUI application from application state.
|
|
||||||
func NewApplication(state *State) *Application {
|
|
||||||
a := &Application{
|
|
||||||
tviewApp: tview.NewApplication(),
|
|
||||||
state: state,
|
|
||||||
}
|
|
||||||
a.tviewApp.EnableMouse(false)
|
|
||||||
a.buildUI()
|
|
||||||
a.wireCallbacks()
|
|
||||||
a.syncDescription()
|
|
||||||
return a
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run starts the TUI event loop and blocks until the application exits.
|
|
||||||
func (a *Application) Run() error {
|
|
||||||
return a.tviewApp.Run()
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildUI constructs all widgets and assembles the layout.
|
|
||||||
func (a *Application) buildUI() {
|
|
||||||
s := a.state
|
|
||||||
|
|
||||||
// --- Widgets ---
|
|
||||||
a.header = components.NewHeaderWidget(s.Appliance.Version, 8)
|
|
||||||
a.appInfo = components.NewApplianceInfoWidget(s.Appliance)
|
|
||||||
a.msgWidget = components.NewMessageWidget()
|
|
||||||
a.msgWidget.SetMessage(s.Message)
|
|
||||||
|
|
||||||
// Determine initial inner padding for menu.
|
|
||||||
a.menuWidget = components.NewMenuWidget(
|
|
||||||
a.state.Navigator.Current().Items,
|
|
||||||
2,
|
|
||||||
)
|
|
||||||
a.menuWidget.SetSelectedIndex(a.state.Navigator.Current().SelectedIdx)
|
|
||||||
|
|
||||||
a.description = components.NewDescriptionWidget()
|
|
||||||
a.footer = components.NewFooterWidget()
|
|
||||||
|
|
||||||
// --- Assemble layout ---
|
|
||||||
a.contentFlex = a.buildContentFlex()
|
|
||||||
|
|
||||||
// Outer flex: left margin | content | right margin
|
|
||||||
a.rootFlex = tview.NewFlex().SetDirection(tview.FlexColumn)
|
|
||||||
a.rootFlex.SetBackgroundColor(theme.ColorBackground)
|
|
||||||
|
|
||||||
a.pages = tview.NewPages()
|
|
||||||
a.pages.SetBackgroundColor(theme.ColorBackground)
|
|
||||||
a.pages.AddPage("main", a.rootFlex, true, true)
|
|
||||||
|
|
||||||
// Initial layout pass — will be recalculated on first draw/resize.
|
|
||||||
a.applyConstraints(layout.Calculate(120, 40))
|
|
||||||
|
|
||||||
a.tviewApp.SetRoot(a.pages, true)
|
|
||||||
a.tviewApp.SetFocus(a.menuWidget)
|
|
||||||
|
|
||||||
// Handle terminal resize.
|
|
||||||
a.tviewApp.SetBeforeDrawFunc(func(screen tcell.Screen) bool {
|
|
||||||
tw, th := screen.Size()
|
|
||||||
c := layout.Calculate(tw, th)
|
|
||||||
if c.IsMinimum != a.tooSmall ||
|
|
||||||
c.MarginH != a.constraints.MarginH ||
|
|
||||||
c.MarginV != a.constraints.MarginV ||
|
|
||||||
c.ContentWidth != a.constraints.ContentWidth {
|
|
||||||
a.applyConstraints(c)
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
|
|
||||||
// Global key: Ctrl+C to quit, F1 for help.
|
|
||||||
a.tviewApp.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
|
|
||||||
switch event.Key() {
|
|
||||||
case tcell.KeyCtrlC:
|
|
||||||
a.tviewApp.Stop()
|
|
||||||
return nil
|
|
||||||
case tcell.KeyF1:
|
|
||||||
a.showHelp()
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return event
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildContentFlex assembles the vertical content stack (without outer margins).
|
|
||||||
func (a *Application) buildContentFlex() *tview.Flex {
|
|
||||||
// Spacer utility.
|
|
||||||
spacer := func(rows int) tview.Primitive {
|
|
||||||
box := tview.NewBox().SetBackgroundColor(theme.ColorBackground)
|
|
||||||
_ = rows
|
|
||||||
return box
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine menu height: 2 rows per item (text + blank) + 2 border rows + 2 inset rows.
|
|
||||||
menuHeight := len(a.state.Navigator.Current().Items)*2 + 4
|
|
||||||
|
|
||||||
// Message row: always 1 row tall (hidden by making it 0 when not visible).
|
|
||||||
msgHeight := 0
|
|
||||||
if a.state.Message.IsVisible() {
|
|
||||||
msgHeight = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
cf := tview.NewFlex().SetDirection(tview.FlexRow)
|
|
||||||
cf.SetBackgroundColor(theme.ColorBackground)
|
|
||||||
|
|
||||||
// Row 0: top vertical margin (spacer).
|
|
||||||
cf.AddItem(spacer(1), 1, 0, false) // placeholder; overridden in applyConstraints
|
|
||||||
|
|
||||||
// Row 1: Header.
|
|
||||||
cf.AddItem(a.header, 1, 0, false)
|
|
||||||
|
|
||||||
// Row 2: blank line after header.
|
|
||||||
cf.AddItem(spacer(1), 1, 0, false)
|
|
||||||
|
|
||||||
// Row 3: Appliance info block (product title + blank + 4 metadata rows = 6 rows).
|
|
||||||
cf.AddItem(a.appInfo, 6, 0, false)
|
|
||||||
|
|
||||||
// Row 4: Message row (0 or 1 row).
|
|
||||||
cf.AddItem(a.msgWidget, msgHeight, 0, false)
|
|
||||||
|
|
||||||
// Row 5: blank line before menu.
|
|
||||||
cf.AddItem(spacer(1), 1, 0, false)
|
|
||||||
|
|
||||||
// Row 6: Navigation menu.
|
|
||||||
cf.AddItem(a.menuWidget, menuHeight, 0, false)
|
|
||||||
|
|
||||||
// Row 7: Flexible whitespace — expands to consume remaining vertical space.
|
|
||||||
cf.AddItem(spacer(0), 0, 1, false)
|
|
||||||
|
|
||||||
// Row 8: Contextual description.
|
|
||||||
cf.AddItem(a.description, 1, 0, false)
|
|
||||||
|
|
||||||
// Row 9: blank between description and footer.
|
|
||||||
cf.AddItem(spacer(1), 1, 0, false)
|
|
||||||
|
|
||||||
// Row 10: Footer.
|
|
||||||
cf.AddItem(a.footer, 1, 0, false)
|
|
||||||
|
|
||||||
// Row 11: bottom vertical margin.
|
|
||||||
cf.AddItem(spacer(1), 1, 0, false)
|
|
||||||
|
|
||||||
return cf
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyConstraints rebuilds margins and updates child widget parameters.
|
|
||||||
func (a *Application) applyConstraints(c layout.Constraints) {
|
|
||||||
a.constraints = c
|
|
||||||
a.tooSmall = c.IsMinimum
|
|
||||||
|
|
||||||
// Clear and rebuild the outer flex.
|
|
||||||
a.rootFlex.Clear()
|
|
||||||
if c.IsMinimum {
|
|
||||||
// Show a minimal warning box.
|
|
||||||
warn := tview.NewBox().SetBackgroundColor(theme.ColorBackground)
|
|
||||||
a.rootFlex.AddItem(warn, 0, 1, false)
|
|
||||||
a.tviewApp.SetFocus(warn)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update header margin.
|
|
||||||
a.header.SetMarginH(c.MarginH)
|
|
||||||
a.header.Refresh(c.TermWidth)
|
|
||||||
|
|
||||||
// Update footer density.
|
|
||||||
a.footer.Refresh(c.TermWidth)
|
|
||||||
|
|
||||||
// Update menu inner padding.
|
|
||||||
a.menuWidget.SetInnerPad(c.MenuInnerPad)
|
|
||||||
|
|
||||||
// Update menu height based on current items.
|
|
||||||
menuItemCount := len(a.state.Navigator.Current().Items)
|
|
||||||
menuHeight := menuItemCount*2 + 4
|
|
||||||
|
|
||||||
// Message visibility.
|
|
||||||
msgHeight := 0
|
|
||||||
if a.state.Message.IsVisible() && c.MessageVisible {
|
|
||||||
msgHeight = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rebuild content flex items with updated sizes.
|
|
||||||
// We rebuild the whole content flex to keep sizing in sync.
|
|
||||||
a.contentFlex = a.rebuildContentFlex(c, menuHeight, msgHeight)
|
|
||||||
|
|
||||||
// Outer layout: left margin | content | right margin.
|
|
||||||
a.rootFlex.AddItem(tview.NewBox().SetBackgroundColor(theme.ColorBackground), c.MarginH, 0, false)
|
|
||||||
a.rootFlex.AddItem(a.contentFlex, 0, 1, true)
|
|
||||||
a.rootFlex.AddItem(tview.NewBox().SetBackgroundColor(theme.ColorBackground), c.MarginH, 0, false)
|
|
||||||
|
|
||||||
a.tviewApp.SetFocus(a.menuWidget)
|
|
||||||
}
|
|
||||||
|
|
||||||
// rebuildContentFlex constructs a fresh vertical flex with updated sizing.
|
|
||||||
func (a *Application) rebuildContentFlex(c layout.Constraints, menuHeight, msgHeight int) *tview.Flex {
|
|
||||||
spacer := func() *tview.Box {
|
|
||||||
return tview.NewBox().SetBackgroundColor(theme.ColorBackground)
|
|
||||||
}
|
|
||||||
|
|
||||||
cf := tview.NewFlex().SetDirection(tview.FlexRow)
|
|
||||||
cf.SetBackgroundColor(theme.ColorBackground)
|
|
||||||
|
|
||||||
cf.AddItem(spacer(), c.MarginV, 0, false)
|
|
||||||
cf.AddItem(a.header, 1, 0, false)
|
|
||||||
cf.AddItem(spacer(), 1, 0, false)
|
|
||||||
cf.AddItem(a.appInfo, 6, 0, false)
|
|
||||||
|
|
||||||
if c.MessageVisible {
|
|
||||||
cf.AddItem(a.msgWidget, msgHeight, 0, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
cf.AddItem(spacer(), 1, 0, false)
|
|
||||||
cf.AddItem(a.menuWidget, menuHeight, 0, true)
|
|
||||||
cf.AddItem(spacer(), 0, 1, false) // flexible whitespace
|
|
||||||
cf.AddItem(a.description, 1, 0, false)
|
|
||||||
cf.AddItem(spacer(), 1, 0, false)
|
|
||||||
cf.AddItem(a.footer, 1, 0, false)
|
|
||||||
cf.AddItem(spacer(), c.MarginV, 0, false)
|
|
||||||
|
|
||||||
return cf
|
|
||||||
}
|
|
||||||
|
|
||||||
// wireCallbacks connects navigation events to UI updates.
|
|
||||||
func (a *Application) wireCallbacks() {
|
|
||||||
a.menuWidget.SetOnSelectionChanged(func(idx int) {
|
|
||||||
a.state.Navigator.Current().SelectedIdx = idx
|
|
||||||
a.syncDescription()
|
|
||||||
})
|
|
||||||
|
|
||||||
a.menuWidget.SetOnEnter(func(idx int) {
|
|
||||||
item := a.state.Navigator.SelectedItem()
|
|
||||||
if item == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !a.state.Navigator.Enter() {
|
|
||||||
// Leaf item — show not-implemented notice.
|
|
||||||
a.showNotImplemented(item.Label)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
// Descended into submenu: refresh menu widget.
|
|
||||||
a.refreshMenuForCurrentLevel()
|
|
||||||
})
|
|
||||||
|
|
||||||
a.menuWidget.SetOnBack(func() {
|
|
||||||
if a.state.Navigator.Back() {
|
|
||||||
a.refreshMenuForCurrentLevel()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// refreshMenuForCurrentLevel updates the menu widget to reflect the current
|
|
||||||
// navigation level and recalculates layout.
|
|
||||||
func (a *Application) refreshMenuForCurrentLevel() {
|
|
||||||
cur := a.state.Navigator.Current()
|
|
||||||
a.menuWidget.SetItems(cur.Items)
|
|
||||||
a.menuWidget.SetSelectedIndex(cur.SelectedIdx)
|
|
||||||
a.syncDescription()
|
|
||||||
// Recalculate layout with new menu item count.
|
|
||||||
a.applyConstraints(a.constraints)
|
|
||||||
}
|
|
||||||
|
|
||||||
// syncDescription updates the contextual description for the selected item.
|
|
||||||
func (a *Application) syncDescription() {
|
|
||||||
item := a.state.Navigator.SelectedItem()
|
|
||||||
if item == nil {
|
|
||||||
a.description.SetDescription("")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
a.description.SetDescription(item.Description)
|
|
||||||
}
|
|
||||||
|
|
||||||
// showNotImplemented displays the not-implemented modal.
|
|
||||||
func (a *Application) showNotImplemented(label string) {
|
|
||||||
modal := components.NewNotImplementedModal(label, func() {
|
|
||||||
a.pages.RemovePage("modal")
|
|
||||||
a.tviewApp.SetFocus(a.menuWidget)
|
|
||||||
})
|
|
||||||
a.pages.AddPage("modal", modal, true, true)
|
|
||||||
a.tviewApp.SetFocus(modal)
|
|
||||||
}
|
|
||||||
|
|
||||||
// showHelp displays the help modal.
|
|
||||||
func (a *Application) showHelp() {
|
|
||||||
helpModal := components.HelpModal(func() {
|
|
||||||
a.pages.RemovePage("help")
|
|
||||||
a.tviewApp.SetFocus(a.menuWidget)
|
|
||||||
})
|
|
||||||
a.pages.AddPage("help", helpModal, true, true)
|
|
||||||
a.tviewApp.SetFocus(helpModal)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetInnerPad exposes the inner padding setter on MenuWidget for use by app.
|
|
||||||
// (Defined here so app package can call it without modifying the component.)
|
|
||||||
func init() {}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
// Package app wires together the Nebara OS TUI application.
|
|
||||||
package app
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/nebara/nebara-os/internal/mock"
|
|
||||||
"github.com/nebara/nebara-os/internal/model"
|
|
||||||
"github.com/nebara/nebara-os/internal/ui/navigation"
|
|
||||||
)
|
|
||||||
|
|
||||||
// State holds the complete runtime state of the TUI application.
|
|
||||||
type State struct {
|
|
||||||
Appliance model.ApplianceInfo
|
|
||||||
Message *model.SystemMessage
|
|
||||||
Navigator *navigation.Navigator
|
|
||||||
ShowModal bool
|
|
||||||
ModalLabel string
|
|
||||||
ShowHelp bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewStateFromMock constructs application state from a mock scenario.
|
|
||||||
func NewStateFromMock(s mock.State) *State {
|
|
||||||
nav := navigation.NewNavigator(model.MainMenu(), s.InitialMenu)
|
|
||||||
return &State{
|
|
||||||
Appliance: s.Appliance,
|
|
||||||
Message: s.Message,
|
|
||||||
Navigator: nav,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
// Package mock provides predefined appliance states for prototype validation.
|
|
||||||
package mock
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/nebara/nebara-os/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
// State bundles all data required to render a complete TUI screen.
|
|
||||||
type State struct {
|
|
||||||
Appliance model.ApplianceInfo
|
|
||||||
Message *model.SystemMessage
|
|
||||||
InitialMenu int // zero-based index into model.MainMenu()
|
|
||||||
}
|
|
||||||
|
|
||||||
// WarningState reproduces Figma mock state A.
|
|
||||||
func WarningState() State {
|
|
||||||
return State{
|
|
||||||
Appliance: model.ApplianceInfo{
|
|
||||||
ProductName: "Nebara S3GX Appliance",
|
|
||||||
Version: "1.0.1",
|
|
||||||
Hostname: "jkt-s3gx-lin-01.domain.local",
|
|
||||||
Address: "10.249.23.13/26",
|
|
||||||
Uptime: "132d 12h 32m",
|
|
||||||
Status: "Healthy",
|
|
||||||
},
|
|
||||||
Message: &model.SystemMessage{
|
|
||||||
Severity: model.SeverityWarning,
|
|
||||||
Text: "[Warn] Update Available to version 2.1.4!",
|
|
||||||
},
|
|
||||||
InitialMenu: 2, // Security & Access
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// NormalState reproduces Figma mock state B.
|
|
||||||
func NormalState() State {
|
|
||||||
return State{
|
|
||||||
Appliance: model.ApplianceInfo{
|
|
||||||
ProductName: "Nebara S3GX Appliance",
|
|
||||||
Version: "2.1.4",
|
|
||||||
Hostname: "sin-s3gx-lin-05.domain.local",
|
|
||||||
Address: "10.175.15.11/25",
|
|
||||||
Uptime: "111d 14h 21m",
|
|
||||||
Status: "Healthy",
|
|
||||||
},
|
|
||||||
Message: nil,
|
|
||||||
InitialMenu: 3, // System Maintenance
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
package mock_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/nebara/nebara-os/internal/mock"
|
|
||||||
"github.com/nebara/nebara-os/internal/model"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestWarningState(t *testing.T) {
|
|
||||||
s := mock.WarningState()
|
|
||||||
|
|
||||||
if s.Appliance.ProductName == "" {
|
|
||||||
t.Error("ProductName should not be empty")
|
|
||||||
}
|
|
||||||
if s.Appliance.Version != "1.0.1" {
|
|
||||||
t.Errorf("expected version 1.0.1, got %s", s.Appliance.Version)
|
|
||||||
}
|
|
||||||
if s.Message == nil {
|
|
||||||
t.Fatal("warning state should have a message")
|
|
||||||
}
|
|
||||||
if s.Message.Severity != model.SeverityWarning {
|
|
||||||
t.Error("expected SeverityWarning")
|
|
||||||
}
|
|
||||||
if !s.Message.IsVisible() {
|
|
||||||
t.Error("warning message should be visible")
|
|
||||||
}
|
|
||||||
if s.InitialMenu < 0 || s.InitialMenu >= 6 {
|
|
||||||
t.Errorf("InitialMenu out of range: %d", s.InitialMenu)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNormalState(t *testing.T) {
|
|
||||||
s := mock.NormalState()
|
|
||||||
|
|
||||||
if s.Appliance.ProductName == "" {
|
|
||||||
t.Error("ProductName should not be empty")
|
|
||||||
}
|
|
||||||
if s.Appliance.Version != "2.1.4" {
|
|
||||||
t.Errorf("expected version 2.1.4, got %s", s.Appliance.Version)
|
|
||||||
}
|
|
||||||
if s.Message != nil && s.Message.IsVisible() {
|
|
||||||
t.Error("normal state should have no visible message")
|
|
||||||
}
|
|
||||||
if s.InitialMenu < 0 || s.InitialMenu >= 6 {
|
|
||||||
t.Errorf("InitialMenu out of range: %d", s.InitialMenu)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWarningState_CorrectMenuSelection(t *testing.T) {
|
|
||||||
s := mock.WarningState()
|
|
||||||
menu := model.MainMenu()
|
|
||||||
if s.InitialMenu >= len(menu) {
|
|
||||||
t.Fatalf("InitialMenu %d exceeds menu length %d", s.InitialMenu, len(menu))
|
|
||||||
}
|
|
||||||
if menu[s.InitialMenu].Label != "Security & Access" {
|
|
||||||
t.Errorf("warning state should select 'Security & Access', got %q", menu[s.InitialMenu].Label)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNormalState_CorrectMenuSelection(t *testing.T) {
|
|
||||||
s := mock.NormalState()
|
|
||||||
menu := model.MainMenu()
|
|
||||||
if s.InitialMenu >= len(menu) {
|
|
||||||
t.Fatalf("InitialMenu %d exceeds menu length %d", s.InitialMenu, len(menu))
|
|
||||||
}
|
|
||||||
if menu[s.InitialMenu].Label != "System Maintenance" {
|
|
||||||
t.Errorf("normal state should select 'System Maintenance', got %q", menu[s.InitialMenu].Label)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemMessage_IsVisible_Nil(t *testing.T) {
|
|
||||||
var m *model.SystemMessage
|
|
||||||
if m.IsVisible() {
|
|
||||||
t.Error("nil message should not be visible")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemMessage_IsVisible_Empty(t *testing.T) {
|
|
||||||
m := &model.SystemMessage{Severity: model.SeverityWarning, Text: ""}
|
|
||||||
if m.IsVisible() {
|
|
||||||
t.Error("empty text message should not be visible")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSystemMessage_IsVisible_SeverityNone(t *testing.T) {
|
|
||||||
m := &model.SystemMessage{Severity: model.SeverityNone, Text: "some text"}
|
|
||||||
if m.IsVisible() {
|
|
||||||
t.Error("SeverityNone message should not be visible")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
// Package model defines the data structures for the Nebara OS TUI.
|
|
||||||
package model
|
|
||||||
|
|
||||||
// ApplianceInfo holds the display data for the appliance information section.
|
|
||||||
type ApplianceInfo struct {
|
|
||||||
ProductName string
|
|
||||||
Version string
|
|
||||||
Hostname string
|
|
||||||
Address string
|
|
||||||
Uptime string
|
|
||||||
Status string
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
// MenuItem represents a single entry in any menu level.
|
|
||||||
type MenuItem struct {
|
|
||||||
Label string
|
|
||||||
Description string
|
|
||||||
Children []MenuItem
|
|
||||||
}
|
|
||||||
|
|
||||||
// MainMenu returns the complete main menu data structure.
|
|
||||||
func MainMenu() []MenuItem {
|
|
||||||
return []MenuItem{
|
|
||||||
{
|
|
||||||
Label: "System Configuration",
|
|
||||||
Description: "Configure appliance hostname, system identity, time and general settings.",
|
|
||||||
Children: []MenuItem{
|
|
||||||
{Label: "Hostname & Domain", Description: "Configure the appliance hostname and domain name."},
|
|
||||||
{Label: "Date & Time", Description: "Set the system date and time."},
|
|
||||||
{Label: "NTP Configuration", Description: "Configure Network Time Protocol servers."},
|
|
||||||
{Label: "System Information", Description: "View system hardware and software information."},
|
|
||||||
{Label: "Appliance Identity", Description: "Configure appliance identity and asset tags."},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Label: "Network Configuration",
|
|
||||||
Description: "Configure management interfaces, IP addressing, DNS and network connectivity.",
|
|
||||||
Children: []MenuItem{
|
|
||||||
{Label: "Management Interfaces", Description: "Configure management network interfaces."},
|
|
||||||
{Label: "IPv4 Configuration", Description: "Configure IPv4 addresses and subnet masks."},
|
|
||||||
{Label: "DNS Configuration", Description: "Configure DNS resolver settings."},
|
|
||||||
{Label: "Default Gateway & Routes", Description: "Configure default gateway and static routes."},
|
|
||||||
{Label: "VLAN Configuration", Description: "Configure VLAN tagging and interfaces."},
|
|
||||||
{Label: "Network Bonding", Description: "Configure interface bonding and aggregation."},
|
|
||||||
{Label: "Connectivity Test", Description: "Test network connectivity to targets."},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Label: "Security & Access",
|
|
||||||
Description: "Manage user accounts, authentication and remote access.",
|
|
||||||
Children: []MenuItem{
|
|
||||||
{Label: "Local Users", Description: "Manage local user accounts."},
|
|
||||||
{Label: "Authentication Settings", Description: "Configure authentication methods."},
|
|
||||||
{Label: "Password Policy", Description: "Configure password complexity and expiry policies."},
|
|
||||||
{Label: "Remote Access", Description: "Configure SSH and remote management access."},
|
|
||||||
{Label: "Session Management", Description: "View and manage active sessions."},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Label: "System Maintenance",
|
|
||||||
Description: "Manage appliance updates, configuration backups and maintenance tasks.",
|
|
||||||
Children: []MenuItem{
|
|
||||||
{Label: "Appliance Updates", Description: "Check for and apply appliance software updates."},
|
|
||||||
{Label: "Configuration Backup", Description: "Back up the current appliance configuration."},
|
|
||||||
{Label: "Configuration Restore", Description: "Restore appliance configuration from a backup."},
|
|
||||||
{Label: "System Restart", Description: "Safely restart the appliance."},
|
|
||||||
{Label: "System Shutdown", Description: "Safely shut down the appliance."},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Label: "Diagnostics",
|
|
||||||
Description: "View system health, diagnostics and troubleshooting information.",
|
|
||||||
Children: []MenuItem{
|
|
||||||
{Label: "System Health", Description: "View overall system health status."},
|
|
||||||
{Label: "Hardware Information", Description: "View hardware component information."},
|
|
||||||
{Label: "Network Diagnostics", Description: "Run network diagnostic tools."},
|
|
||||||
{Label: "Service Status", Description: "View the status of system services."},
|
|
||||||
{Label: "System Logs", Description: "Browse and filter system log entries."},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Label: "Support & Recovery",
|
|
||||||
Description: "Access support tools, recovery options and troubleshooting functions.",
|
|
||||||
Children: []MenuItem{
|
|
||||||
{Label: "Support Information", Description: "View support contact and licensing information."},
|
|
||||||
{Label: "Diagnostic Bundle", Description: "Generate a diagnostic bundle for support."},
|
|
||||||
{Label: "Recovery Options", Description: "Access factory reset and recovery options."},
|
|
||||||
{Label: "Troubleshooting Shell", Description: "Not available in this prototype."},
|
|
||||||
{Label: "Support Access", Description: "Manage remote support access credentials."},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
package model
|
|
||||||
|
|
||||||
// MessageSeverity classifies the severity of a system message.
|
|
||||||
type MessageSeverity int
|
|
||||||
|
|
||||||
const (
|
|
||||||
// SeverityNone means no message is active.
|
|
||||||
SeverityNone MessageSeverity = iota
|
|
||||||
SeverityWarning
|
|
||||||
SeverityError
|
|
||||||
SeverityInfo
|
|
||||||
)
|
|
||||||
|
|
||||||
// SystemMessage represents an optional system-level status message.
|
|
||||||
type SystemMessage struct {
|
|
||||||
Severity MessageSeverity
|
|
||||||
Text string
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsVisible returns true when there is an active message to display.
|
|
||||||
func (m *SystemMessage) IsVisible() bool {
|
|
||||||
return m != nil && m.Severity != SeverityNone && m.Text != ""
|
|
||||||
}
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
package components
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/gdamore/tcell/v2"
|
|
||||||
"github.com/nebara/nebara-os/internal/model"
|
|
||||||
"github.com/nebara/nebara-os/internal/ui/theme"
|
|
||||||
"github.com/rivo/tview"
|
|
||||||
)
|
|
||||||
|
|
||||||
// HeaderWidget renders the NEBARA - OS logo and version on the same row.
|
|
||||||
// The "O" character in "OS" is rendered in the logo accent color (#B30D18).
|
|
||||||
// All other characters use the primary foreground (#F5F5F5).
|
|
||||||
//
|
|
||||||
// Terminal limitation: tview.TextView supports color tags for mixed-color
|
|
||||||
// text on a single line, which we use here.
|
|
||||||
type HeaderWidget struct {
|
|
||||||
*tview.TextView
|
|
||||||
version string
|
|
||||||
marginH int
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewHeaderWidget creates the header with the given version string and margin.
|
|
||||||
func NewHeaderWidget(version string, marginH int) *HeaderWidget {
|
|
||||||
tv := tview.NewTextView().
|
|
||||||
SetDynamicColors(true).
|
|
||||||
SetScrollable(false)
|
|
||||||
tv.SetBackgroundColor(theme.ColorBackground)
|
|
||||||
|
|
||||||
h := &HeaderWidget{
|
|
||||||
TextView: tv,
|
|
||||||
version: version,
|
|
||||||
marginH: marginH,
|
|
||||||
}
|
|
||||||
h.refresh(80) // initial width estimate; recalculated on draw
|
|
||||||
return h
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetVersion updates the version string and redraws.
|
|
||||||
func (h *HeaderWidget) SetVersion(v string) {
|
|
||||||
h.version = v
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetMarginH updates the horizontal margin.
|
|
||||||
func (h *HeaderWidget) SetMarginH(m int) {
|
|
||||||
h.marginH = m
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh recalculates the header text for the current terminal width.
|
|
||||||
func (h *HeaderWidget) Refresh(termWidth int) {
|
|
||||||
h.refresh(termWidth)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *HeaderWidget) refresh(termWidth int) {
|
|
||||||
// Logo: "NEBARA - OS" with "O" in accent red.
|
|
||||||
// tview color tag format: [#RRGGBB]
|
|
||||||
logoPlain := "NEBARA - "
|
|
||||||
// "O" accent, "S" back to primary
|
|
||||||
logoColored := fmt.Sprintf(
|
|
||||||
"[%s]%s[%s]O[%s]S",
|
|
||||||
theme.HexPrimary,
|
|
||||||
logoPlain,
|
|
||||||
theme.HexLogoAccent,
|
|
||||||
theme.HexPrimary,
|
|
||||||
)
|
|
||||||
|
|
||||||
versionText := "Version " + h.version
|
|
||||||
|
|
||||||
// The content width between the margins for text layout purposes.
|
|
||||||
// Visible character count for the logo is 11 ("NEBARA - OS").
|
|
||||||
// Visible character count for version: len("Version ") + len(version).
|
|
||||||
logoVisibleLen := len("NEBARA - OS")
|
|
||||||
versionVisibleLen := len(versionText)
|
|
||||||
contentWidth := termWidth - h.marginH*2
|
|
||||||
|
|
||||||
// Number of spaces between logo and version to right-align version.
|
|
||||||
spaces := contentWidth - logoVisibleLen - versionVisibleLen
|
|
||||||
if spaces < 1 {
|
|
||||||
spaces = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
line := logoColored + strings.Repeat(" ", spaces) + versionText
|
|
||||||
h.TextView.SetText(line)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ApplianceInfoWidget renders the appliance metadata block.
|
|
||||||
type ApplianceInfoWidget struct {
|
|
||||||
*tview.TextView
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewApplianceInfoWidget creates the appliance info display.
|
|
||||||
func NewApplianceInfoWidget(info model.ApplianceInfo) *ApplianceInfoWidget {
|
|
||||||
tv := tview.NewTextView().
|
|
||||||
SetDynamicColors(true).
|
|
||||||
SetScrollable(false)
|
|
||||||
tv.SetBackgroundColor(theme.ColorBackground)
|
|
||||||
|
|
||||||
w := &ApplianceInfoWidget{TextView: tv}
|
|
||||||
w.SetInfo(info)
|
|
||||||
return w
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetInfo refreshes the appliance information display.
|
|
||||||
func (w *ApplianceInfoWidget) SetInfo(info model.ApplianceInfo) {
|
|
||||||
// Product title is bold; labels and values use regular weight.
|
|
||||||
// tview bold tag: [::b] ; reset: [::-]
|
|
||||||
text := fmt.Sprintf(
|
|
||||||
"[%s][::b]%s[::-]\n\n[%s]Hostname: %s\n[%s]Address : %s\n[%s]Uptime : %s\n[%s]Status : %s",
|
|
||||||
theme.HexPrimary, info.ProductName,
|
|
||||||
theme.HexPrimary, info.Hostname,
|
|
||||||
theme.HexPrimary, info.Address,
|
|
||||||
theme.HexPrimary, info.Uptime,
|
|
||||||
theme.HexPrimary, info.Status,
|
|
||||||
)
|
|
||||||
w.TextView.SetText(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MessageWidget renders an optional severity-classified system message.
|
|
||||||
type MessageWidget struct {
|
|
||||||
*tview.TextView
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewMessageWidget creates the message widget (initially empty).
|
|
||||||
func NewMessageWidget() *MessageWidget {
|
|
||||||
tv := tview.NewTextView().
|
|
||||||
SetDynamicColors(true).
|
|
||||||
SetScrollable(false)
|
|
||||||
tv.SetBackgroundColor(theme.ColorBackground)
|
|
||||||
return &MessageWidget{TextView: tv}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetMessage updates the displayed message. Pass nil to clear.
|
|
||||||
func (w *MessageWidget) SetMessage(msg *model.SystemMessage) {
|
|
||||||
if msg == nil || !msg.IsVisible() {
|
|
||||||
w.TextView.SetText("")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var hexColor string
|
|
||||||
switch msg.Severity {
|
|
||||||
case model.SeverityWarning:
|
|
||||||
hexColor = theme.HexWarning
|
|
||||||
case model.SeverityError:
|
|
||||||
hexColor = theme.HexError
|
|
||||||
case model.SeverityInfo:
|
|
||||||
hexColor = theme.HexInfo
|
|
||||||
default:
|
|
||||||
hexColor = theme.HexPrimary
|
|
||||||
}
|
|
||||||
|
|
||||||
// "Message : " label in primary color, then the tagged text in severity color.
|
|
||||||
text := fmt.Sprintf("[%s]Message : [%s]%s",
|
|
||||||
theme.HexPrimary, hexColor, tview.Escape(msg.Text))
|
|
||||||
w.TextView.SetText(text)
|
|
||||||
}
|
|
||||||
|
|
||||||
// DescriptionWidget shows the contextual description for the selected menu item.
|
|
||||||
type DescriptionWidget struct {
|
|
||||||
*tview.TextView
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewDescriptionWidget creates the description area.
|
|
||||||
func NewDescriptionWidget() *DescriptionWidget {
|
|
||||||
tv := tview.NewTextView().
|
|
||||||
SetDynamicColors(true).
|
|
||||||
SetScrollable(false).
|
|
||||||
SetWrap(true)
|
|
||||||
tv.SetBackgroundColor(theme.ColorBackground)
|
|
||||||
return &DescriptionWidget{TextView: tv}
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetDescription updates the displayed contextual description.
|
|
||||||
func (w *DescriptionWidget) SetDescription(text string) {
|
|
||||||
w.TextView.SetText(fmt.Sprintf("[%s]%s", theme.HexSecondary, tview.Escape(text)))
|
|
||||||
}
|
|
||||||
|
|
||||||
// FooterWidget renders the keyboard shortcut hint row.
|
|
||||||
type FooterWidget struct {
|
|
||||||
*tview.TextView
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewFooterWidget creates the keyboard hint footer.
|
|
||||||
func NewFooterWidget() *FooterWidget {
|
|
||||||
tv := tview.NewTextView().
|
|
||||||
SetDynamicColors(true).
|
|
||||||
SetScrollable(false)
|
|
||||||
tv.SetBackgroundColor(theme.ColorBackground)
|
|
||||||
|
|
||||||
f := &FooterWidget{TextView: tv}
|
|
||||||
f.Refresh(120)
|
|
||||||
return f
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh redraws the footer at the appropriate density for the terminal width.
|
|
||||||
func (f *FooterWidget) Refresh(termWidth int) {
|
|
||||||
if termWidth < 80 {
|
|
||||||
// Compact form.
|
|
||||||
f.TextView.SetText(fmt.Sprintf("[%s][::b]↑↓[-] Nav [::b]↵[-] Sel [::b]Esc[-] Back [::b]F1[-] Help",
|
|
||||||
theme.HexPrimary))
|
|
||||||
} else {
|
|
||||||
f.TextView.SetText(fmt.Sprintf("[%s][::b]↑↓[-] Navigate [::b]Enter[-] [::b]Select[-] [::b]Esc[-] [::b]Back[-] [::b]F1[-] [::b]Help[-]",
|
|
||||||
theme.HexPrimary))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ModalWidget is a simple centered dialog box for "not implemented" notices.
|
|
||||||
type ModalWidget struct {
|
|
||||||
*tview.Modal
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewNotImplementedModal creates a minimal "Not Implemented" modal.
|
|
||||||
func NewNotImplementedModal(itemLabel string, onDismiss func()) *tview.Modal {
|
|
||||||
msg := fmt.Sprintf("[ %s ]\n\nThis function is not available in the\nNebara OS TUI Prototype v0.1.\n\nPress Enter or Escape to return.", itemLabel)
|
|
||||||
modal := tview.NewModal().
|
|
||||||
SetText(msg).
|
|
||||||
AddButtons([]string{"OK"}).
|
|
||||||
SetDoneFunc(func(_ int, _ string) {
|
|
||||||
if onDismiss != nil {
|
|
||||||
onDismiss()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
modal.SetBackgroundColor(theme.ColorBackground)
|
|
||||||
modal.SetBorderColor(theme.ColorBorder)
|
|
||||||
modal.SetTextColor(theme.ColorSecondary)
|
|
||||||
return modal
|
|
||||||
}
|
|
||||||
|
|
||||||
// HelpModal returns a modal displaying keyboard shortcuts.
|
|
||||||
func HelpModal(onDismiss func()) *tview.Modal {
|
|
||||||
text := "NEBARA OS — KEYBOARD SHORTCUTS\n\n" +
|
|
||||||
"↑ / ↓ Navigate menu items\n" +
|
|
||||||
"Enter Open submenu / confirm\n" +
|
|
||||||
"Esc Return to previous menu\n" +
|
|
||||||
"Home Jump to first item\n" +
|
|
||||||
"End Jump to last item\n" +
|
|
||||||
"F1 Show this help\n" +
|
|
||||||
"Ctrl+C Quit application"
|
|
||||||
modal := tview.NewModal().
|
|
||||||
SetText(text).
|
|
||||||
AddButtons([]string{"Close"}).
|
|
||||||
SetDoneFunc(func(_ int, _ string) {
|
|
||||||
if onDismiss != nil {
|
|
||||||
onDismiss()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
modal.SetBackgroundColor(theme.ColorBackground)
|
|
||||||
modal.SetBorderColor(theme.ColorBorder)
|
|
||||||
modal.SetTextColor(theme.ColorSecondary)
|
|
||||||
return modal
|
|
||||||
}
|
|
||||||
|
|
||||||
// MinSizeWarning renders a compact message when the terminal is too small.
|
|
||||||
func MinSizeWarning(screen tcell.Screen, w, h int) {
|
|
||||||
msg := " Terminal too small. Minimum: 60×20 "
|
|
||||||
style := tcell.StyleDefault.Background(theme.ColorError).Foreground(theme.ColorBackground)
|
|
||||||
x := (w - len(msg)) / 2
|
|
||||||
y := h / 2
|
|
||||||
if x < 0 {
|
|
||||||
x = 0
|
|
||||||
}
|
|
||||||
for i, ch := range msg {
|
|
||||||
screen.SetContent(x+i, y, ch, nil, style)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
// 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
// Package layout provides responsive dimension calculations for the Nebara OS TUI.
|
|
||||||
// All functions operate on terminal cell dimensions, not pixels.
|
|
||||||
package layout
|
|
||||||
|
|
||||||
// Constraints holds calculated layout values for a given terminal size.
|
|
||||||
type Constraints struct {
|
|
||||||
TermWidth int
|
|
||||||
TermHeight int
|
|
||||||
|
|
||||||
// Outer horizontal margin in cells (applied to both left and right sides).
|
|
||||||
MarginH int
|
|
||||||
// Outer vertical margin in cells (applied to top and bottom).
|
|
||||||
MarginV int
|
|
||||||
|
|
||||||
// ContentWidth is the usable width between the two horizontal margins.
|
|
||||||
ContentWidth int
|
|
||||||
|
|
||||||
// AppInfoLines is the number of rows reserved for appliance information
|
|
||||||
// (product title + 4 metadata rows).
|
|
||||||
AppInfoLines int
|
|
||||||
|
|
||||||
// MessageVisible indicates whether the message row should be shown.
|
|
||||||
// On very compact terminals it may be suppressed.
|
|
||||||
MessageVisible bool
|
|
||||||
|
|
||||||
// MenuInnerPad is the horizontal padding inside the menu border.
|
|
||||||
MenuInnerPad int
|
|
||||||
|
|
||||||
// FooterLines is the number of rows occupied by the footer (description + shortcuts).
|
|
||||||
FooterLines int
|
|
||||||
|
|
||||||
// IsMinimum flags terminals smaller than the supported minimum.
|
|
||||||
IsMinimum bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// MinWidth is the smallest terminal width the application supports gracefully.
|
|
||||||
const MinWidth = 60
|
|
||||||
|
|
||||||
// MinHeight is the smallest terminal height the application supports gracefully.
|
|
||||||
const MinHeight = 20
|
|
||||||
|
|
||||||
// Calculate derives layout constraints from terminal dimensions.
|
|
||||||
// Reference: Figma frame 1920×1080 with 80px outer margins (4.17% of width)
|
|
||||||
// and 60px vertical margins (5.56% of height).
|
|
||||||
func Calculate(termW, termH int) Constraints {
|
|
||||||
if termW < MinWidth || termH < MinHeight {
|
|
||||||
return Constraints{
|
|
||||||
TermWidth: termW,
|
|
||||||
TermHeight: termH,
|
|
||||||
IsMinimum: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Horizontal margin: ~4.17% of width, min 4 cells, max 12 cells.
|
|
||||||
marginH := clamp(termW*417/10000, 4, 12)
|
|
||||||
|
|
||||||
// Vertical margin: ~5.56% of height, min 1 cell, max 4 cells.
|
|
||||||
marginV := clamp(termH*556/10000, 1, 4)
|
|
||||||
|
|
||||||
contentWidth := termW - marginH*2
|
|
||||||
|
|
||||||
// Menu inner horizontal padding (space between border char and text).
|
|
||||||
menuInnerPad := 2
|
|
||||||
if termW < 100 {
|
|
||||||
menuInnerPad = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Message row visibility: suppress on very compact terminals.
|
|
||||||
messageVisible := termH >= 24
|
|
||||||
|
|
||||||
return Constraints{
|
|
||||||
TermWidth: termW,
|
|
||||||
TermHeight: termH,
|
|
||||||
MarginH: marginH,
|
|
||||||
MarginV: marginV,
|
|
||||||
ContentWidth: contentWidth,
|
|
||||||
AppInfoLines: 6, // blank + product title + blank + 4 metadata rows
|
|
||||||
MessageVisible: messageVisible,
|
|
||||||
MenuInnerPad: menuInnerPad,
|
|
||||||
FooterLines: 2,
|
|
||||||
IsMinimum: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func clamp(v, lo, hi int) int {
|
|
||||||
if v < lo {
|
|
||||||
return lo
|
|
||||||
}
|
|
||||||
if v > hi {
|
|
||||||
return hi
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
package layout_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/nebara/nebara-os/internal/ui/layout"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestCalculate_Normal(t *testing.T) {
|
|
||||||
c := layout.Calculate(120, 40)
|
|
||||||
if c.IsMinimum {
|
|
||||||
t.Error("120×40 should not be minimum")
|
|
||||||
}
|
|
||||||
if c.ContentWidth <= 0 {
|
|
||||||
t.Error("ContentWidth should be positive")
|
|
||||||
}
|
|
||||||
if c.MarginH <= 0 {
|
|
||||||
t.Error("MarginH should be positive")
|
|
||||||
}
|
|
||||||
if c.ContentWidth != c.TermWidth-c.MarginH*2 {
|
|
||||||
t.Errorf("ContentWidth mismatch: got %d, expected %d", c.ContentWidth, c.TermWidth-c.MarginH*2)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCalculate_TooSmall(t *testing.T) {
|
|
||||||
c := layout.Calculate(40, 10)
|
|
||||||
if !c.IsMinimum {
|
|
||||||
t.Error("40×10 should be flagged as minimum/too-small")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCalculate_BoundaryMinWidth(t *testing.T) {
|
|
||||||
c := layout.Calculate(layout.MinWidth, layout.MinHeight)
|
|
||||||
if c.IsMinimum {
|
|
||||||
t.Errorf("exact minimum %dx%d should not be flagged as too small", layout.MinWidth, layout.MinHeight)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCalculate_MarginClamps(t *testing.T) {
|
|
||||||
// Very wide terminal: margin should not exceed 12.
|
|
||||||
cWide := layout.Calculate(400, 80)
|
|
||||||
if cWide.MarginH > 12 {
|
|
||||||
t.Errorf("MarginH should not exceed 12 on very wide terminal, got %d", cWide.MarginH)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Narrow but valid terminal: margin should not go below 4.
|
|
||||||
cNarrow := layout.Calculate(60, 20)
|
|
||||||
if cNarrow.MarginH < 4 {
|
|
||||||
t.Errorf("MarginH should be at least 4 on narrow terminal, got %d", cNarrow.MarginH)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCalculate_MessageVisibility(t *testing.T) {
|
|
||||||
cCompact := layout.Calculate(80, 23)
|
|
||||||
if cCompact.MessageVisible {
|
|
||||||
t.Error("message should be hidden on terminals shorter than 24 rows")
|
|
||||||
}
|
|
||||||
cNormal := layout.Calculate(80, 24)
|
|
||||||
if !cNormal.MessageVisible {
|
|
||||||
t.Error("message should be visible at 24 rows")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCalculate_MenuInnerPad(t *testing.T) {
|
|
||||||
cNarrow := layout.Calculate(80, 24)
|
|
||||||
if cNarrow.MenuInnerPad != 1 {
|
|
||||||
t.Errorf("expected menu inner pad 1 for narrow terminal, got %d", cNarrow.MenuInnerPad)
|
|
||||||
}
|
|
||||||
cWide := layout.Calculate(120, 40)
|
|
||||||
if cWide.MenuInnerPad != 2 {
|
|
||||||
t.Errorf("expected menu inner pad 2 for wide terminal, got %d", cWide.MenuInnerPad)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
// Package navigation implements the menu navigation stack for Nebara OS TUI.
|
|
||||||
package navigation
|
|
||||||
|
|
||||||
import "github.com/nebara/nebara-os/internal/model"
|
|
||||||
|
|
||||||
// Level represents one level in the navigation stack.
|
|
||||||
type Level struct {
|
|
||||||
Items []model.MenuItem
|
|
||||||
SelectedIdx int
|
|
||||||
}
|
|
||||||
|
|
||||||
// Navigator maintains a stack of menu levels and tracks the current position.
|
|
||||||
type Navigator struct {
|
|
||||||
stack []Level
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewNavigator creates a Navigator rooted at the main menu.
|
|
||||||
func NewNavigator(items []model.MenuItem, initialIdx int) *Navigator {
|
|
||||||
if initialIdx < 0 || initialIdx >= len(items) {
|
|
||||||
initialIdx = 0
|
|
||||||
}
|
|
||||||
return &Navigator{
|
|
||||||
stack: []Level{
|
|
||||||
{Items: items, SelectedIdx: initialIdx},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Current returns the active menu level.
|
|
||||||
func (n *Navigator) Current() *Level {
|
|
||||||
if len(n.stack) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &n.stack[len(n.stack)-1]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Depth returns the current nesting depth (0 = main menu).
|
|
||||||
func (n *Navigator) Depth() int {
|
|
||||||
return len(n.stack) - 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// IsAtRoot reports whether the navigator is at the main menu level.
|
|
||||||
func (n *Navigator) IsAtRoot() bool {
|
|
||||||
return len(n.stack) == 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// SelectedItem returns the currently highlighted menu item.
|
|
||||||
func (n *Navigator) SelectedItem() *model.MenuItem {
|
|
||||||
cur := n.Current()
|
|
||||||
if cur == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &cur.Items[cur.SelectedIdx]
|
|
||||||
}
|
|
||||||
|
|
||||||
// MoveUp moves the selection one row up, clamping at the first item.
|
|
||||||
func (n *Navigator) MoveUp() {
|
|
||||||
cur := n.Current()
|
|
||||||
if cur == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if cur.SelectedIdx > 0 {
|
|
||||||
cur.SelectedIdx--
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MoveDown moves the selection one row down, clamping at the last item.
|
|
||||||
func (n *Navigator) MoveDown() {
|
|
||||||
cur := n.Current()
|
|
||||||
if cur == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if cur.SelectedIdx < len(cur.Items)-1 {
|
|
||||||
cur.SelectedIdx++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MoveFirst jumps to the first item.
|
|
||||||
func (n *Navigator) MoveFirst() {
|
|
||||||
cur := n.Current()
|
|
||||||
if cur != nil {
|
|
||||||
cur.SelectedIdx = 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MoveLast jumps to the last item.
|
|
||||||
func (n *Navigator) MoveLast() {
|
|
||||||
cur := n.Current()
|
|
||||||
if cur != nil {
|
|
||||||
cur.SelectedIdx = len(cur.Items) - 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enter descends into the submenu of the currently selected item.
|
|
||||||
// Returns false when the item has no children (leaf node).
|
|
||||||
func (n *Navigator) Enter() bool {
|
|
||||||
item := n.SelectedItem()
|
|
||||||
if item == nil || len(item.Children) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
n.stack = append(n.stack, Level{
|
|
||||||
Items: item.Children,
|
|
||||||
SelectedIdx: 0,
|
|
||||||
})
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Back pops the current level from the stack, returning to the parent.
|
|
||||||
// Returns false when already at the root.
|
|
||||||
func (n *Navigator) Back() bool {
|
|
||||||
if len(n.stack) <= 1 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
n.stack = n.stack[:len(n.stack)-1]
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// BreadcrumbTitle builds a display title for the current navigation depth.
|
|
||||||
// It returns empty string when at the root.
|
|
||||||
func (n *Navigator) BreadcrumbTitle() string {
|
|
||||||
if len(n.stack) <= 1 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
// Return the label of the parent item whose children we are browsing.
|
|
||||||
parent := n.stack[len(n.stack)-2]
|
|
||||||
return parent.Items[parent.SelectedIdx].Label
|
|
||||||
}
|
|
||||||
@@ -1,170 +0,0 @@
|
|||||||
package navigation_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/nebara/nebara-os/internal/model"
|
|
||||||
"github.com/nebara/nebara-os/internal/ui/navigation"
|
|
||||||
)
|
|
||||||
|
|
||||||
func sampleItems() []model.MenuItem {
|
|
||||||
return []model.MenuItem{
|
|
||||||
{Label: "Item A", Description: "Desc A", Children: []model.MenuItem{
|
|
||||||
{Label: "Child A1", Description: "Desc A1"},
|
|
||||||
{Label: "Child A2", Description: "Desc A2"},
|
|
||||||
}},
|
|
||||||
{Label: "Item B", Description: "Desc B"},
|
|
||||||
{Label: "Item C", Description: "Desc C"},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewNavigator_InitialIndex(t *testing.T) {
|
|
||||||
nav := navigation.NewNavigator(sampleItems(), 1)
|
|
||||||
if nav.Current().SelectedIdx != 1 {
|
|
||||||
t.Errorf("expected initial index 1, got %d", nav.Current().SelectedIdx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestNewNavigator_InvalidIndex(t *testing.T) {
|
|
||||||
nav := navigation.NewNavigator(sampleItems(), 99)
|
|
||||||
if nav.Current().SelectedIdx != 0 {
|
|
||||||
t.Errorf("out-of-range index should clamp to 0, got %d", nav.Current().SelectedIdx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMoveDown(t *testing.T) {
|
|
||||||
nav := navigation.NewNavigator(sampleItems(), 0)
|
|
||||||
nav.MoveDown()
|
|
||||||
if nav.Current().SelectedIdx != 1 {
|
|
||||||
t.Errorf("expected index 1 after MoveDown, got %d", nav.Current().SelectedIdx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMoveDown_Clamps(t *testing.T) {
|
|
||||||
items := sampleItems()
|
|
||||||
nav := navigation.NewNavigator(items, len(items)-1)
|
|
||||||
nav.MoveDown()
|
|
||||||
if nav.Current().SelectedIdx != len(items)-1 {
|
|
||||||
t.Errorf("MoveDown should clamp at last item")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMoveUp(t *testing.T) {
|
|
||||||
nav := navigation.NewNavigator(sampleItems(), 2)
|
|
||||||
nav.MoveUp()
|
|
||||||
if nav.Current().SelectedIdx != 1 {
|
|
||||||
t.Errorf("expected index 1 after MoveUp, got %d", nav.Current().SelectedIdx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMoveUp_Clamps(t *testing.T) {
|
|
||||||
nav := navigation.NewNavigator(sampleItems(), 0)
|
|
||||||
nav.MoveUp()
|
|
||||||
if nav.Current().SelectedIdx != 0 {
|
|
||||||
t.Errorf("MoveUp should clamp at 0")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMoveFirst(t *testing.T) {
|
|
||||||
nav := navigation.NewNavigator(sampleItems(), 2)
|
|
||||||
nav.MoveFirst()
|
|
||||||
if nav.Current().SelectedIdx != 0 {
|
|
||||||
t.Errorf("MoveFirst should jump to 0")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMoveLast(t *testing.T) {
|
|
||||||
nav := navigation.NewNavigator(sampleItems(), 0)
|
|
||||||
nav.MoveLast()
|
|
||||||
last := len(sampleItems()) - 1
|
|
||||||
if nav.Current().SelectedIdx != last {
|
|
||||||
t.Errorf("MoveLast should jump to %d, got %d", last, nav.Current().SelectedIdx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnter_HasChildren(t *testing.T) {
|
|
||||||
nav := navigation.NewNavigator(sampleItems(), 0) // Item A has children
|
|
||||||
ok := nav.Enter()
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("Enter on item with children should return true")
|
|
||||||
}
|
|
||||||
if nav.Depth() != 1 {
|
|
||||||
t.Errorf("expected depth 1, got %d", nav.Depth())
|
|
||||||
}
|
|
||||||
if nav.Current().Items[0].Label != "Child A1" {
|
|
||||||
t.Errorf("expected Child A1, got %s", nav.Current().Items[0].Label)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestEnter_NoChildren(t *testing.T) {
|
|
||||||
nav := navigation.NewNavigator(sampleItems(), 1) // Item B has no children
|
|
||||||
ok := nav.Enter()
|
|
||||||
if ok {
|
|
||||||
t.Fatal("Enter on leaf item should return false")
|
|
||||||
}
|
|
||||||
if nav.Depth() != 0 {
|
|
||||||
t.Errorf("depth should remain 0 for leaf enter")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBack(t *testing.T) {
|
|
||||||
nav := navigation.NewNavigator(sampleItems(), 0)
|
|
||||||
nav.Enter()
|
|
||||||
ok := nav.Back()
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("Back from submenu should return true")
|
|
||||||
}
|
|
||||||
if nav.Depth() != 0 {
|
|
||||||
t.Errorf("expected depth 0 after back, got %d", nav.Depth())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBack_AtRoot(t *testing.T) {
|
|
||||||
nav := navigation.NewNavigator(sampleItems(), 0)
|
|
||||||
ok := nav.Back()
|
|
||||||
if ok {
|
|
||||||
t.Fatal("Back at root should return false")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsAtRoot(t *testing.T) {
|
|
||||||
nav := navigation.NewNavigator(sampleItems(), 0)
|
|
||||||
if !nav.IsAtRoot() {
|
|
||||||
t.Error("should be at root initially")
|
|
||||||
}
|
|
||||||
nav.Enter()
|
|
||||||
if nav.IsAtRoot() {
|
|
||||||
t.Error("should not be at root after Enter")
|
|
||||||
}
|
|
||||||
nav.Back()
|
|
||||||
if !nav.IsAtRoot() {
|
|
||||||
t.Error("should be at root after Back")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSelectionPreservedAfterBack(t *testing.T) {
|
|
||||||
nav := navigation.NewNavigator(sampleItems(), 0)
|
|
||||||
nav.MoveDown() // idx=1
|
|
||||||
nav.MoveDown() // idx=2
|
|
||||||
nav.Back() // at root, nothing changes
|
|
||||||
if nav.Current().SelectedIdx != 2 {
|
|
||||||
t.Errorf("selection should be 2, got %d", nav.Current().SelectedIdx)
|
|
||||||
}
|
|
||||||
nav.MoveFirst() // idx=0
|
|
||||||
nav.Enter() // into Item A's children
|
|
||||||
nav.Back() // back to root, parent should restore idx=0
|
|
||||||
if nav.Current().SelectedIdx != 0 {
|
|
||||||
t.Errorf("after returning from submenu, root selection should be 0, got %d", nav.Current().SelectedIdx)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestBreadcrumbTitle(t *testing.T) {
|
|
||||||
nav := navigation.NewNavigator(sampleItems(), 0)
|
|
||||||
if nav.BreadcrumbTitle() != "" {
|
|
||||||
t.Error("breadcrumb should be empty at root")
|
|
||||||
}
|
|
||||||
nav.Enter()
|
|
||||||
if nav.BreadcrumbTitle() != "Item A" {
|
|
||||||
t.Errorf("breadcrumb should be 'Item A', got %q", nav.BreadcrumbTitle())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
// Package theme defines centralized color constants and text styles
|
|
||||||
// for the Nebara OS TUI design system.
|
|
||||||
package theme
|
|
||||||
|
|
||||||
import "github.com/gdamore/tcell/v2"
|
|
||||||
|
|
||||||
// Color design tokens — do not modify these values.
|
|
||||||
// They are final design specifications from the Nebara OS design system.
|
|
||||||
const (
|
|
||||||
HexBackground = "#000000"
|
|
||||||
HexPrimary = "#F5F5F5"
|
|
||||||
HexSecondary = "#BBBBBB"
|
|
||||||
HexBorder = "#BBBBBB"
|
|
||||||
HexSelectionBg = "#BBBBBB"
|
|
||||||
HexSelectionFg = "#000000"
|
|
||||||
HexWarning = "#FFCC00"
|
|
||||||
HexError = "#B30D18"
|
|
||||||
HexInfo = "#0088FF"
|
|
||||||
HexLogoAccent = "#B30D18"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Resolved tcell colors from the design token hex values.
|
|
||||||
var (
|
|
||||||
ColorBackground = tcell.NewHexColor(0x000000)
|
|
||||||
ColorPrimary = tcell.NewHexColor(0xF5F5F5)
|
|
||||||
ColorSecondary = tcell.NewHexColor(0xBBBBBB)
|
|
||||||
ColorBorder = tcell.NewHexColor(0xBBBBBB)
|
|
||||||
ColorSelectionBg = tcell.NewHexColor(0xBBBBBB)
|
|
||||||
ColorSelectionFg = tcell.NewHexColor(0x000000)
|
|
||||||
ColorWarning = tcell.NewHexColor(0xFFCC00)
|
|
||||||
ColorError = tcell.NewHexColor(0xB30D18)
|
|
||||||
ColorInfo = tcell.NewHexColor(0x0088FF)
|
|
||||||
ColorLogoAccent = tcell.NewHexColor(0xB30D18)
|
|
||||||
)
|
|
||||||
|
|
||||||
// StyleNormal is the default text style: primary foreground on black.
|
|
||||||
var StyleNormal = tcell.StyleDefault.
|
|
||||||
Background(ColorBackground).
|
|
||||||
Foreground(ColorPrimary)
|
|
||||||
|
|
||||||
// StyleSecondary is used for labels and secondary information.
|
|
||||||
var StyleSecondary = tcell.StyleDefault.
|
|
||||||
Background(ColorBackground).
|
|
||||||
Foreground(ColorSecondary)
|
|
||||||
|
|
||||||
// StyleBold is used for product title and header text.
|
|
||||||
var StyleBold = tcell.StyleDefault.
|
|
||||||
Background(ColorBackground).
|
|
||||||
Foreground(ColorPrimary).
|
|
||||||
Bold(true)
|
|
||||||
|
|
||||||
// StyleSelected is the highlighted menu row style.
|
|
||||||
var StyleSelected = tcell.StyleDefault.
|
|
||||||
Background(ColorSelectionBg).
|
|
||||||
Foreground(ColorSelectionFg)
|
|
||||||
|
|
||||||
// StyleWarning applies the warning foreground color.
|
|
||||||
var StyleWarning = tcell.StyleDefault.
|
|
||||||
Background(ColorBackground).
|
|
||||||
Foreground(ColorWarning)
|
|
||||||
|
|
||||||
// StyleError applies the error foreground color.
|
|
||||||
var StyleError = tcell.StyleDefault.
|
|
||||||
Background(ColorBackground).
|
|
||||||
Foreground(ColorError)
|
|
||||||
|
|
||||||
// StyleInfo applies the info foreground color.
|
|
||||||
var StyleInfo = tcell.StyleDefault.
|
|
||||||
Background(ColorBackground).
|
|
||||||
Foreground(ColorInfo)
|
|
||||||
Reference in New Issue
Block a user