327 lines
9.3 KiB
Go
327 lines
9.3 KiB
Go
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() {}
|