Commit: Initial Update
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
# Nebara OS TUI — Visual Baseline v0.1
|
||||
|
||||
Native terminal management interface for the **Nebara S3GX Appliance**, implemented in Go using `tview` + `tcell/v2`.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This is a **visual prototype** using mock appliance data only.
|
||||
> No real system configuration, privileged operations, or hardware interaction occurs.
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Nebara OS is a common appliance operating environment for enterprise infrastructure products.
|
||||
This prototype establishes the TUI visual foundation for the Nebara S3GX Appliance,
|
||||
reproducing the Figma reference mockups as faithfully as a native terminal environment allows.
|
||||
|
||||
The interface is intentionally minimal and industrial — inspired by enterprise console UIs like VMware ESXi DCUI.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
| Requirement | Version |
|
||||
|---|---|
|
||||
| Go | ≥ 1.21 |
|
||||
| Terminal | True-color 256+ color support |
|
||||
| Recommended font | JetBrains Mono (any weight) |
|
||||
|
||||
### Check Go version
|
||||
|
||||
```bash
|
||||
go version
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
git clone <repo>
|
||||
cd nebara-os
|
||||
go mod tidy
|
||||
go build -o nebara-tui ./cmd/nebara-tui
|
||||
```
|
||||
|
||||
On Windows:
|
||||
|
||||
```powershell
|
||||
go build -o nebara-tui.exe ./cmd/nebara-tui
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
# Warning state (default) with SIXEL logo
|
||||
./nebara-tui --mock-state=warning --sixel=true
|
||||
|
||||
# Normal state with SIXEL logo
|
||||
./nebara-tui --mock-state=normal --sixel=true
|
||||
|
||||
# Fallback to pure text colored logo
|
||||
./nebara-tui --sixel=false
|
||||
```
|
||||
|
||||
Or via `go run`:
|
||||
|
||||
```bash
|
||||
go run ./cmd/nebara-tui --mock-state=warning --sixel=true
|
||||
go run ./cmd/nebara-tui --mock-state=normal --sixel=false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mock States
|
||||
|
||||
### State A — Warning (`--mock-state=warning`)
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Product | 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 | `[Warn] Update Available to version 2.1.4!` |
|
||||
| Initial selection | Security & Access |
|
||||
|
||||
### State B — Normal (`--mock-state=normal`)
|
||||
|
||||
| Field | Value |
|
||||
|---|---|
|
||||
| Product | 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 | None |
|
||||
| Initial selection | System Maintenance |
|
||||
|
||||
---
|
||||
|
||||
## Keyboard Controls
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `↑` / `↓` | Navigate menu items |
|
||||
| `Enter` | Open submenu or confirm |
|
||||
| `Esc` | Return to parent menu |
|
||||
| `Home` | Jump to first item |
|
||||
| `End` | Jump to last item |
|
||||
| `F1` | Show keyboard shortcut help |
|
||||
| `Ctrl+C` | Quit application |
|
||||
|
||||
---
|
||||
|
||||
## Recommended Terminal Settings
|
||||
|
||||
For the best visual experience:
|
||||
|
||||
- **Font:** JetBrains Mono (Regular / Bold) — available from [jetbrains.com/lp/mono](https://www.jetbrains.com/lp/mono/)
|
||||
- **Color support:** True-color (24-bit) — set `COLORTERM=truecolor` if needed
|
||||
- **Background:** Black (`#000000`) — ensure terminal background matches
|
||||
- **Minimum size:** 60 columns × 20 rows (warning shown if smaller)
|
||||
- **Recommended size:** 120 columns × 40 rows or larger
|
||||
|
||||
### Windows Terminal
|
||||
|
||||
```json
|
||||
{
|
||||
"font": { "face": "JetBrains Mono", "size": 14 },
|
||||
"colorScheme": "One Half Dark"
|
||||
}
|
||||
```
|
||||
|
||||
Set `COLORTERM=truecolor` in your shell profile for 24-bit colors.
|
||||
|
||||
---
|
||||
|
||||
## Responsive Layout
|
||||
|
||||
The layout adapts to terminal dimensions using percentage-based margins derived from the Figma reference (1920×1080, 80px outer margins ≈ 4.17% of width):
|
||||
|
||||
| Terminal Size | Behavior |
|
||||
|---|---|
|
||||
| 192×54 and wider | Spacious; full Figma proportions |
|
||||
| 120×40 | Normal desktop; full metadata |
|
||||
| 100×30 | Compact; reduced whitespace |
|
||||
| 80×24 | Minimum practical target; message row suppressed below 24 rows |
|
||||
| Below 60×20 | Size warning overlay; no crash |
|
||||
|
||||
Live resizing is handled; no restart required.
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
nebara-os/
|
||||
├── cmd/
|
||||
│ └── nebara-tui/
|
||||
│ └── main.go # CLI entry point, --mock-state flag
|
||||
│
|
||||
├── internal/
|
||||
│ ├── app/
|
||||
│ │ ├── application.go # TUI wiring: layout, callbacks, modals
|
||||
│ │ └── state.go # Runtime application state struct
|
||||
│ │
|
||||
│ ├── model/
|
||||
│ │ ├── appliance.go # ApplianceInfo data model
|
||||
│ │ ├── message.go # SystemMessage + severity types
|
||||
│ │ └── menu.go # MenuItem tree + MainMenu() factory
|
||||
│ │
|
||||
│ ├── ui/
|
||||
│ │ ├── theme/
|
||||
│ │ │ └── colors.go # Centralized color tokens & tcell styles
|
||||
│ │ │
|
||||
│ │ ├── layout/
|
||||
│ │ │ └── responsive.go # Terminal→cell layout constraint calculator
|
||||
│ │ │
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── header.go # Header, ApplianceInfo, Message, Description, Footer
|
||||
│ │ │ └── menu.go # Custom MenuWidget (full-width row highlight)
|
||||
│ │ │
|
||||
│ │ └── navigation/
|
||||
│ │ └── navigator.go # Navigation stack (Enter/Back/Up/Down)
|
||||
│ │
|
||||
│ └── mock/
|
||||
│ └── states.go # WarningState() and NormalState() factories
|
||||
│
|
||||
├── go.mod
|
||||
├── go.sum
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Design System
|
||||
|
||||
### Colors
|
||||
|
||||
| Token | Hex | Usage |
|
||||
|---|---|---|
|
||||
| Background | `#000000` | Main screen background |
|
||||
| Primary | `#F5F5F5` | Default foreground text |
|
||||
| Secondary | `#BBBBBB` | Labels, descriptions, borders |
|
||||
| Selection BG | `#BBBBBB` | Selected menu row background |
|
||||
| Selection FG | `#000000` | Selected menu row text |
|
||||
| Warning | `#FFCC00` | Warning message text |
|
||||
| Error | `#B30D18` | Error messages; logo "O" accent |
|
||||
| Info | `#0088FF` | Informational messages |
|
||||
|
||||
### Typography Hierarchy
|
||||
|
||||
The terminal cannot control font family per-component. Hierarchy is expressed through:
|
||||
|
||||
- **Bold** — product title, footer key labels
|
||||
- **Regular** — metadata labels, values, menu items, descriptions
|
||||
- **Color** — severity-classified messages, logo accent
|
||||
|
||||
---
|
||||
|
||||
## Known Terminal Rendering Limitations
|
||||
|
||||
| Figma Specification | Terminal Equivalent |
|
||||
|---|---|
|
||||
| Michroma 24px logo font | Terminal monospace font; layout preserved |
|
||||
| JetBrains Mono Bold/Regular by size | Bold attribute; font configured per-terminal |
|
||||
| 1px border (outside alignment) | Single-cell-width tview box border |
|
||||
| Pixel-precise spacing | Cell-based proportional spacing |
|
||||
| Shadow effects | Not reproducible in terminals |
|
||||
| Rounded corners | Not applicable; square box characters used |
|
||||
| True-color hex values | Full 24-bit color via tcell on supported terminals |
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
go test ./... # Run all tests
|
||||
go test ./... -v # Verbose output
|
||||
go vet ./... # Static analysis
|
||||
go fmt ./... # Format check
|
||||
```
|
||||
|
||||
### Test Coverage
|
||||
|
||||
| Package | Tests |
|
||||
|---|---|
|
||||
| `internal/mock` | Mock state correctness, message visibility |
|
||||
| `internal/ui/layout` | Margin clamping, message visibility, compact behavior |
|
||||
| `internal/ui/navigation` | All navigation transitions, stack depth, breadcrumbs |
|
||||
|
||||
### Manual Validation Steps
|
||||
|
||||
1. Launch both mock states and compare against reference screenshots
|
||||
2. Navigate all 6 main menu items with ↑/↓ — verify description updates
|
||||
3. Press Enter on each main menu item — verify submenu appears with correct title
|
||||
4. Navigate submenu items — verify ↑/↓ work
|
||||
5. Press Esc from submenu — verify return to main menu with original selection
|
||||
6. Press Enter on a leaf (submenu item) — verify not-implemented modal appears
|
||||
7. Press F1 — verify help modal appears
|
||||
8. Resize terminal live — verify no flickering or corruption
|
||||
9. Shrink below 60×20 — verify size warning appears
|
||||
|
||||
---
|
||||
|
||||
## SIXEL Logo Integration POC
|
||||
|
||||
The application includes an experimental, isolated proof-of-concept for rendering the official PNG logo using native SIXEL graphics escape sequences:
|
||||
|
||||
### Technical Architecture
|
||||
|
||||
1. **Embedded Asset:** The official 221×19 px PNG logo is embedded via `//go:embed` inside `internal/ui/sixel`.
|
||||
2. **SIXEL Encoding:** The asset is converted into DECSIXEL format at startup via `github.com/mattn/go-sixel`, producing a compact 3.9 KB payload.
|
||||
3. **Screen Buffer Synchronization (`screen.LockRegion`):** During header rendering, the 26-column × 1-row area occupied by the logo is locked in `tcell.Screen`. This guarantees `tcell`'s dirty cell tracking skips those coordinates, preventing the screen engine from overwriting the graphic with background spaces or text on subsequent redraw cycles.
|
||||
4. **Direct-to-TTY Output:** The SIXEL payload is written directly to the terminal output device (`CONOUT$` on Windows, `/dev/tty` on UNIX) with ANSI cursor save (`\x1b[s`), positioning (`\x1b[%d;%dH`), and restore (`\x1b[u`).
|
||||
5. **Redraw & Resize Lifecycle:** The logo automatically re-emits when terminal geometry changes (e.g., live window resize) or when modal overlays (such as the F1 Help dialog) are dismissed.
|
||||
6. **Deadlock Prevention:** `SetFocus()` is strictly avoided inside draw callbacks (`SetBeforeDrawFunc`), ensuring mutex safety during live window resizing.
|
||||
7. **Graceful Fallback:** Pass `--sixel=false` or resize below minimum width to revert seamlessly to the colored text logo.
|
||||
|
||||
---
|
||||
|
||||
## Future Integration Boundaries
|
||||
|
||||
This prototype establishes the visual foundation. Future phases will:
|
||||
|
||||
- Connect to a privileged management daemon via Unix socket or gRPC
|
||||
- Add real `ApplianceInfo` collection (hostname, IP, uptime) without root
|
||||
- Implement `System Configuration`, `Network Configuration`, etc. as real forms
|
||||
- Support product-specific modules (UNFZFS, Tape, Backup) via adapter pattern
|
||||
- Add authentication gate before main menu on production builds
|
||||
|
||||
The TUI must never require root privileges. Privileged operations route through a separate daemon.
|
||||
|
||||
---
|
||||
|
||||
*Nebara OS TUI — Visual Baseline v0.1 — Prototype only — Mock data only*
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1 @@
|
||||
P0;1;8q"1;1;221;19#0;2;0;0;0#1;2;62;62;62#2;2;43;43;43#3;2;30;30;30#4;2;49;49;49#5;2;56;56;56#6;2;6;6;6#7;2;12;12;12#8;2;24;24;24#9;2;21;1;3#10;2;43;2;5#11;2;56;3;7#12;2;61;4;8#13;2;70;5;9#14;2;52;3;7#15;2;17;1;2#16;2;68;68;68#17;2;87;87;87#18;2;93;93;93#19;2;100;100;100#20;2;74;74;74#21;2;12;12;12#22;2;37;37;37#23;2;4;0;0#24;2;39;2;5#25;2;81;81;81#26;2;25;25;25#27;2;18;18;18#28;2;31;31;31#29;2;37;37;37#30;2;18;18;18#31;2;44;3;5#32;2;65;4;9#33;2;34;2;4#34;2;8;0;1#35;2;13;0;1#36;2;30;1;3#37;2;25;1;3#38;2;39;2;5#39;2;61;4;8#40;2;65;4;8#41;2;8;0;1#42;2;8;0;1#43;2;52;3;7#44;2;21;1;2#45;2;61;4;8#46;2;26;1;3#47;2;30;1;4#48;2;17;1;2#49;2;4;0;0#50;2;22;1;3#51;2;43;3;5#52;2;48;3;6#1@@!19?@!6?@D!14@!5?@D!13@!17?A@@@!14?!14@???o?o!13?@@H$#2??H!19?@!45?o!10?O!33?C@??AG$#3!7?O!12?@!44?@!32?@!38?O!60?G$#4!8?_!11?}!6?@?w!18?@?w!13?@!17?G??_!12?}!14?@!15?O??G??_!62?oA$#5???A!40?@!39?GA!28?C!20?A!65?D!15?@$#6!65?G@?C!15?O@??_!26?G!19?@??_!64?C!13?O$#7???O_!73?_??@!46?_??@??O!63?C$#8!86?C!28?@??C!81?@!16?G?A$#9!173?O??@$#10!177?@!13?@$#11!178?@$#12!179?@!9?@$#13!174?wK!4A!9BAAAE[o$#14!175?A!14?@$#15!192?@$#16!65?C!65?A!70?@$#17???GOG!38?A!21?A!12?_GO!33?CA!13?GO!5?_!65?@!11?@?C$#18!6?O!59?GC!17?O?_!28?G!12?_!5?O!64?G???@$#19}}ECGO__!13?}!6?}!15A!5?}!16ACw!12?oKEAEKo!12?}!16AC{!12?oKEAEKo!62?{E!4A!10BAAA]W$#20!22?}!4?}!20?}!37?G!13?w!35?G!63?O!15?@?__$#21!5?CG!75?O!135?@?G$#22!67?AG!11?C_?C???O!42?C_?C$#23!174?A$#24!193?A$#25!4?C_!60?o!33?C!99?_!18?C$#26??o!27?!14C!7?!13C!138?C!17?O$#27!44?C!171?C???_$#28!64?C$#29!101?!12C$#30!136?C$#31!174?C!16?C$#32!176?C!16?_$#33!173?_???C$#34!173?G!4?C$#35!190?C$#36!194?C$#37!176?G$#38!192?G$#39!194?G$#40!175?O$#41!192?O$#42!195?O$#43!175?_$#44!195?_-$#1!66?C!11?@C!36?@?@!9?@C!8?O!60?C?A!15?O$#2!14?O!126?_$#3!8?C!109?C$#4!7?A!7?_!4?~!8?b!20?p!24?_??O!9?@??_!6?~!15?_C!12?O!9?@!15?_!8O_!53?C$#5!6?@??@!19?O!20?A!16?AO!19?O!28?GA!6?_!93?G_$#6!11?_!32?O!23?G_!5?O!9?@??_!25?C!20?@!67?A??O$#7!9?GO!19?!14O!21?@!11?A!23?!11_!13?O!12?_!68?!7O$#8!66?_!19?AG??G!46?G??G!13?O!8?O!34?A!19?C?O$#10!175?~$#13!174?~!19?~$#14!193?[$#16!12?_!54?G!47?O!23?C!61?G!14?C$#17!10?GSG!16?C!20?G!14?A!10?Oc??@!5?@???O!10?!12G?O!11?Oc??@!5?@!70?!7C$#18!9?C!55?CO?_!19?A_!23?G!24?A_!60?@??G$#19~~!5?@BACGOo_!6?~!6?~!15G!5?~!14CKGJp!8?_WMB!7?BKW_!8?~W!12OoGGKF!8?_WMB!7?BKW_!14?!8_!36?BECCCKKK!11GWo$#20!10?A!11?~!4?~??!14C!4?~??!12G!24?C?C!10?f!11?_???A!20?C!77?C$#21!11?AC!38?!12A!15?_G!9?A!39?GA!8?A$#22!64?AO!10?G!49?G!9?A!63?G$#25!63?G!76?O!61?G!11?C???_$#26??~!41?G!23?@!11?A!35?O!97?OO$#27!44?C$#28!216?O$#29!198?@$#30!13?G!49?A!63?A!74?A$#31!173?~$#33!195?[$#45!193?b$#46!195?b-$#1!64?O!9?aG??!11@???_!21?G?G!8?G??!11@!60?@??O!15?B?B$#2!67?_!4?O!125?A?@!15?O$#3!144?O!53?@!16?O$#4!13?@A!5?F!8?N!15O!5?N!13O??G?O!29?~!16?_!6?O?_$#5!19?_!72?A!22?A!26?A!77?C$#6!17?@!48?@!25?@??_!19?@!26?@??_$#7!16?GO!53?_??@!38?CO!6?_??@!73?G_!17?G$#8!15?C!75?O?C!18?@!4?O!23?O!59?G?!12O$#10!175?@$#11!175?A$#12!191?O$#13!174?FWo!15_O]F$#14!177?O!14?G$#15!173?C!5?!10O???C$#16!16?@!107?A!93?C$#17!17?IC!47?_?G???_G!15?@!33?G!15?@!4?_!55?_!16?OG$#18!16?C!74?@??_!19?@O!6?_!18?@!58?CO!16?_O$#19~~!12?@BACGWo~!6?~!16_!4?~!16_O^F!4?o[FB!13ABE[o!5?~!13?@EKo!6?o[FB!13ABE[o!55?MW!17_ON$#20!22?~!4?~?O!18?~?O!40?G?G!6?~!12?A???_!23?G_G!55?O$#21!18?A!26?O!23?C!5?O!40?C!8?O!94?O$#22!73?C!16?C???O!28?C!16?C!79?G$#23!176?C!18?C$#24!176?G$#25!18?O?G!44?O!11?@!49?@!72?A$#26??~!42?_!23?A!7?!13C!37?!13C$#27!66?C??@$#28!76?C!49?C$#29!155?!8@!35?C$#30!19?C!123?C!10?@!8?@!55?_$#32!174?G!17?_$#33!175?_$#38!173?@$#40!175?C$#42!194?O$#43!194?G$#44!189?O???_?@$#45!193?@$#47!173?AO???O!11?O$#48!195?A$#49!173?G-$#1!203?@!11?@$#3!22?@!77?@$#4!202?@!13?@$#6!115?@??@!99?@$#7!65?@!26?@!49?@$#8!27?@!20?@$#16!204?@!9?@$#20!205?!9@$#21??@!42?@!28?@!20?@!28?@!20?@$#23!192?@$#26!64?@!136?@!15?@$#28!20?@$#29@@!19?@!6?!17@!4?!15@!8?@@!19?@@!4?@!16?@@!4?@@!19?@@$#30!71?@!26?@!22?@$#33!178?@!11?@$#34!176?@$#43!180?!8@$#48!191?@$#50!177?@$#51!179?@!9?@$#52!188?@\
|
||||
@@ -0,0 +1,43 @@
|
||||
// Nebara OS TUI — Visual Baseline v0.1
|
||||
// Native terminal management interface for the Nebara S3GX Appliance.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./cmd/nebara-tui [--mock-state=warning|normal] [--sixel=true|false]
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/nebara/nebara-os/internal/app"
|
||||
"github.com/nebara/nebara-os/internal/mock"
|
||||
)
|
||||
|
||||
func main() {
|
||||
stateFlag := flag.String("mock-state", "warning", "Mock state to display: 'warning' or 'normal'")
|
||||
sixelFlag := flag.Bool("sixel", true, "Enable SIXEL logo rendering in header (default: true)")
|
||||
flag.Parse()
|
||||
|
||||
var s mock.State
|
||||
switch *stateFlag {
|
||||
case "normal":
|
||||
s = mock.NormalState()
|
||||
case "warning":
|
||||
s = mock.WarningState()
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "Unknown mock state %q. Use 'warning' or 'normal'.\n", *stateFlag)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
appState := app.NewStateFromMock(s)
|
||||
appState.EnableSixel = *sixelFlag
|
||||
|
||||
application := app.NewApplication(appState)
|
||||
|
||||
if err := application.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "fatal: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
module github.com/nebara/nebara-os
|
||||
|
||||
go 1.24.0
|
||||
|
||||
require (
|
||||
github.com/gdamore/tcell/v2 v2.7.4
|
||||
github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/gdamore/encoding v1.0.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.15 // indirect
|
||||
github.com/mattn/go-sixel v0.0.14 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/soniakeys/quant v1.0.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/term v0.38.0 // indirect
|
||||
golang.org/x/text v0.14.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,58 @@
|
||||
github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
|
||||
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
|
||||
github.com/gdamore/tcell/v2 v2.7.4 h1:sg6/UnTM9jGpZU+oFYAsDahfchWAFW8Xx2yFinNSAYU=
|
||||
github.com/gdamore/tcell/v2 v2.7.4/go.mod h1:dSXtXTSK0VsW1biw65DZLZ2NKr7j0qP/0J7ONmsraWg=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sixel v0.0.14 h1:ub0R40qVtTHvBKh7GijzNo8v3By3LYjGa9zgaQ17/Es=
|
||||
github.com/mattn/go-sixel v0.0.14/go.mod h1:qwBpwmGTuJaXHixZVwiLIGT8JNO5P0BOGYjUEC6dmK0=
|
||||
github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57 h1:LmsF7Fk5jyEDhJk0fYIqdWNuTxSyid2W42A0L2YWjGE=
|
||||
github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57/go.mod h1:02iFIz7K/A9jGCvrizLPvoqr4cEIx7q54RH5Qudkrss=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/soniakeys/quant v1.0.0 h1:N1um9ktjbkZVcywBVAAYpZYSHxEfJGzshHCxx/DaI0Y=
|
||||
github.com/soniakeys/quant v1.0.0/go.mod h1:HI1k023QuVbD4H8i9YdfZP2munIHU4QpjsImz6Y6zds=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q=
|
||||
golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@@ -0,0 +1,332 @@
|
||||
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()
|
||||
}
|
||||
|
||||
// SetScreen overrides the underlying tcell.Screen (e.g. for simulation testing).
|
||||
func (a *Application) SetScreen(screen tcell.Screen) *Application {
|
||||
a.tviewApp.SetScreen(screen)
|
||||
return a
|
||||
}
|
||||
|
||||
// buildUI constructs all widgets and assembles the layout.
|
||||
func (a *Application) buildUI() {
|
||||
s := a.state
|
||||
|
||||
// --- Widgets ---
|
||||
a.header = components.NewHeaderWidget(s.Appliance.Version, 8, s.EnableSixel)
|
||||
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)
|
||||
a.header.InvalidateSixel()
|
||||
}
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
// 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.header.InvalidateSixel()
|
||||
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.header.InvalidateSixel()
|
||||
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() {}
|
||||
@@ -0,0 +1,95 @@
|
||||
package app_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/nebara/nebara-os/internal/app"
|
||||
"github.com/nebara/nebara-os/internal/mock"
|
||||
)
|
||||
|
||||
func TestApplication_FullLifecycleSimulation(t *testing.T) {
|
||||
simScreen := tcell.NewSimulationScreen("")
|
||||
if err := simScreen.Init(); err != nil {
|
||||
t.Fatalf("simScreen init: %v", err)
|
||||
}
|
||||
|
||||
simScreen.SetSize(120, 40)
|
||||
|
||||
state := app.NewStateFromMock(mock.WarningState())
|
||||
state.EnableSixel = true
|
||||
|
||||
application := app.NewApplication(state)
|
||||
application.SetScreen(simScreen)
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- application.Run()
|
||||
}()
|
||||
|
||||
// Allow initial frame to draw
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// 1. Test navigation (Down, Up)
|
||||
simScreen.InjectKey(tcell.KeyDown, 0, tcell.ModNone)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
simScreen.InjectKey(tcell.KeyUp, 0, tcell.ModNone)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// 2. Test F1 Help Modal open and close
|
||||
simScreen.InjectKey(tcell.KeyF1, 0, tcell.ModNone)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
simScreen.InjectKey(tcell.KeyEscape, 0, tcell.ModNone)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// 3. Test terminal resize (compact size and back)
|
||||
simScreen.SetSize(90, 28)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
simScreen.SetSize(120, 40)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
// 4. Test normal exit (Ctrl+C)
|
||||
simScreen.InjectKey(tcell.KeyCtrlC, 'c', tcell.ModCtrl)
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Errorf("application run error: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("application failed to terminate cleanly (possible deadlock)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplication_TextFallbackLifecycle(t *testing.T) {
|
||||
simScreen := tcell.NewSimulationScreen("")
|
||||
if err := simScreen.Init(); err != nil {
|
||||
t.Fatalf("simScreen init: %v", err)
|
||||
}
|
||||
|
||||
simScreen.SetSize(120, 40)
|
||||
|
||||
state := app.NewStateFromMock(mock.NormalState())
|
||||
state.EnableSixel = false // SIXEL disabled
|
||||
|
||||
application := app.NewApplication(state)
|
||||
application.SetScreen(simScreen)
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- application.Run()
|
||||
}()
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
simScreen.InjectKey(tcell.KeyCtrlC, 'c', tcell.ModCtrl)
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
t.Errorf("application run error: %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("application failed to terminate cleanly")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// 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
|
||||
EnableSixel 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,
|
||||
EnableSixel: true,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
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."},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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 != ""
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/nebara/nebara-os/internal/model"
|
||||
"github.com/nebara/nebara-os/internal/ui/sixel"
|
||||
"github.com/nebara/nebara-os/internal/ui/theme"
|
||||
"github.com/rivo/tview"
|
||||
)
|
||||
|
||||
// HeaderWidget renders the official Nebara OS logo and version on the same row.
|
||||
// When SIXEL is enabled, it renders the official high-fidelity PNG logo directly
|
||||
// into the terminal via SIXEL escape sequences with synchronized tcell cell locking.
|
||||
// Otherwise, it falls back to the colored text logo.
|
||||
type HeaderWidget struct {
|
||||
*tview.TextView
|
||||
version string
|
||||
marginH int
|
||||
sixelRenderer *sixel.LogoRenderer
|
||||
}
|
||||
|
||||
// NewHeaderWidget creates the header with the given version string, margin, and SIXEL setting.
|
||||
func NewHeaderWidget(version string, marginH int, enableSixel bool) *HeaderWidget {
|
||||
tv := tview.NewTextView().
|
||||
SetDynamicColors(true).
|
||||
SetScrollable(false)
|
||||
tv.SetBackgroundColor(theme.ColorBackground)
|
||||
|
||||
var sr *sixel.LogoRenderer
|
||||
if enableSixel {
|
||||
if r, err := sixel.NewLogoRenderer(); err == nil {
|
||||
sr = r
|
||||
}
|
||||
}
|
||||
|
||||
h := &HeaderWidget{
|
||||
TextView: tv,
|
||||
version: version,
|
||||
marginH: marginH,
|
||||
sixelRenderer: sr,
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// InvalidateSixel marks the SIXEL graphic dirty so it re-renders on next draw.
|
||||
func (h *HeaderWidget) InvalidateSixel() {
|
||||
if h.sixelRenderer != nil {
|
||||
h.sixelRenderer.Invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
// SetSixelEnabled toggles SIXEL rendering.
|
||||
func (h *HeaderWidget) SetSixelEnabled(enabled bool) {
|
||||
if h.sixelRenderer != nil {
|
||||
h.sixelRenderer.SetEnabled(enabled)
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh recalculates the header text for the current terminal width.
|
||||
func (h *HeaderWidget) Refresh(termWidth int) {
|
||||
h.refresh(termWidth)
|
||||
}
|
||||
|
||||
// Draw renders the header text and synchronizes the SIXEL logo graphic with tcell.
|
||||
func (h *HeaderWidget) Draw(screen tcell.Screen) {
|
||||
h.TextView.Draw(screen)
|
||||
if h.sixelRenderer != nil && h.sixelRenderer.IsEnabled() {
|
||||
x, y, _, _ := h.TextView.GetInnerRect()
|
||||
_ = h.sixelRenderer.Draw(screen, x, y)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *HeaderWidget) refresh(termWidth int) {
|
||||
versionText := "Version " + h.version
|
||||
contentWidth := termWidth - h.marginH*2
|
||||
versionVisibleLen := len(versionText)
|
||||
|
||||
// If SIXEL is active and content width is sufficient, reserve space for the image
|
||||
if h.sixelRenderer != nil && h.sixelRenderer.IsEnabled() && contentWidth >= h.sixelRenderer.CellCols()+versionVisibleLen+2 {
|
||||
logoCols := h.sixelRenderer.CellCols()
|
||||
spaces := contentWidth - logoCols - versionVisibleLen
|
||||
if spaces < 1 {
|
||||
spaces = 1
|
||||
}
|
||||
line := strings.Repeat(" ", logoCols) + strings.Repeat(" ", spaces) + versionText
|
||||
h.TextView.SetText(line)
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback to text logo: "NEBARA - OS" with "O" in accent red.
|
||||
logoPlain := "NEBARA - "
|
||||
logoColored := fmt.Sprintf(
|
||||
"[%s]%s[%s]O[%s]S",
|
||||
theme.HexPrimary,
|
||||
logoPlain,
|
||||
theme.HexLogoAccent,
|
||||
theme.HexPrimary,
|
||||
)
|
||||
|
||||
logoVisibleLen := len("NEBARA - OS")
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1,188 @@
|
||||
// Package sixel provides SIXEL graphics decoding, cell synchronization,
|
||||
// and terminal rendering for the Nebara OS TUI.
|
||||
package sixel
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"image/png"
|
||||
"io"
|
||||
"os"
|
||||
"runtime"
|
||||
"sync"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
gosixel "github.com/mattn/go-sixel"
|
||||
)
|
||||
|
||||
// DefaultLogoPNG contains the official Nebara OS PNG logo asset.
|
||||
//
|
||||
//go:embed logo.png
|
||||
var DefaultLogoPNG []byte
|
||||
|
||||
const (
|
||||
// DefaultLogoCellWidth is the number of character columns reserved for the logo.
|
||||
// 221px image width / ~8.5-9px cell width ≈ 25-26 columns.
|
||||
DefaultLogoCellWidth = 26
|
||||
|
||||
// DefaultLogoCellHeight is the number of character rows reserved for the logo.
|
||||
// 19px image height fits in 1 row (with breathing room from the spacer below).
|
||||
DefaultLogoCellHeight = 1
|
||||
)
|
||||
|
||||
// LogoRenderer handles encoding and rendering of the SIXEL logo.
|
||||
type LogoRenderer struct {
|
||||
mu sync.Mutex
|
||||
sixelData []byte
|
||||
pixelWidth int
|
||||
pixelHeight int
|
||||
cellCols int
|
||||
cellRows int
|
||||
enabled bool
|
||||
|
||||
lastX int
|
||||
lastY int
|
||||
hasDrawn bool
|
||||
needsDraw bool
|
||||
}
|
||||
|
||||
// NewLogoRenderer initializes the renderer using the official PNG asset.
|
||||
func NewLogoRenderer() (*LogoRenderer, error) {
|
||||
return NewLogoRendererFromPNG(DefaultLogoPNG)
|
||||
}
|
||||
|
||||
// NewLogoRendererFromPNG initializes the renderer from custom PNG bytes.
|
||||
func NewLogoRendererFromPNG(pngBytes []byte) (*LogoRenderer, error) {
|
||||
img, err := png.Decode(bytes.NewReader(pngBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode logo PNG: %w", err)
|
||||
}
|
||||
|
||||
bounds := img.Bounds()
|
||||
pw := bounds.Dx()
|
||||
ph := bounds.Dy()
|
||||
|
||||
var buf bytes.Buffer
|
||||
enc := gosixel.NewEncoder(&buf)
|
||||
enc.Transparent = true
|
||||
if err := enc.Encode(img); err != nil {
|
||||
return nil, fmt.Errorf("encode SIXEL: %w", err)
|
||||
}
|
||||
|
||||
return &LogoRenderer{
|
||||
sixelData: buf.Bytes(),
|
||||
pixelWidth: pw,
|
||||
pixelHeight: ph,
|
||||
cellCols: DefaultLogoCellWidth,
|
||||
cellRows: DefaultLogoCellHeight,
|
||||
enabled: true,
|
||||
lastX: -1,
|
||||
lastY: -1,
|
||||
needsDraw: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SetEnabled toggles SIXEL rendering.
|
||||
func (r *LogoRenderer) SetEnabled(enabled bool) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.enabled = enabled
|
||||
r.needsDraw = true
|
||||
}
|
||||
|
||||
// IsEnabled returns whether SIXEL rendering is currently active.
|
||||
func (r *LogoRenderer) IsEnabled() bool {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.enabled
|
||||
}
|
||||
|
||||
// Invalidate forces a re-render of the SIXEL logo on the next draw cycle.
|
||||
func (r *LogoRenderer) Invalidate() {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.needsDraw = true
|
||||
}
|
||||
|
||||
// CellCols returns the number of terminal character cells the logo occupies.
|
||||
func (r *LogoRenderer) CellCols() int {
|
||||
return r.cellCols
|
||||
}
|
||||
|
||||
// CellRows returns the number of terminal character rows the logo occupies.
|
||||
func (r *LogoRenderer) CellRows() int {
|
||||
return r.cellRows
|
||||
}
|
||||
|
||||
// SixelDataLength returns the byte size of the cached SIXEL stream.
|
||||
func (r *LogoRenderer) SixelDataLength() int {
|
||||
return len(r.sixelData)
|
||||
}
|
||||
|
||||
// Draw locks the tcell screen buffer region and outputs the SIXEL sequence to the TTY.
|
||||
func (r *LogoRenderer) Draw(screen tcell.Screen, x, y int) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if !r.enabled || len(r.sixelData) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 1. Lock the screen region in tcell buffer so tcell.Show() skips these cells.
|
||||
// This prevents tcell from clearing or drawing characters over the logo.
|
||||
screen.LockRegion(x, y, r.cellCols, r.cellRows, true)
|
||||
|
||||
// Check if we need to re-emit the SIXEL data to the terminal.
|
||||
// We re-emit if position changed, or if explicitly invalidated (e.g. after resize/modal).
|
||||
posChanged := (x != r.lastX || y != r.lastY)
|
||||
if !r.hasDrawn || posChanged || r.needsDraw {
|
||||
if err := r.emitSixel(x, y); err != nil {
|
||||
return err
|
||||
}
|
||||
r.lastX = x
|
||||
r.lastY = y
|
||||
r.hasDrawn = true
|
||||
r.needsDraw = false
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// emitSixel writes the escape sequences and SIXEL payload to the active terminal.
|
||||
func (r *LogoRenderer) emitSixel(x, y int) error {
|
||||
tty, err := openTTY()
|
||||
if err != nil {
|
||||
return fmt.Errorf("open TTY: %w", err)
|
||||
}
|
||||
defer tty.Close()
|
||||
|
||||
// ANSI cursor positioning is 1-indexed: row = y + 1, col = x + 1.
|
||||
// Sequence:
|
||||
// \x1b[s : Save current cursor position
|
||||
// \x1b[%d;%dH: Move to target (row, col)
|
||||
// [SIXEL payload]
|
||||
// \x1b[u : Restore cursor position
|
||||
posSeq := fmt.Sprintf("\x1b[s\x1b[%d;%dH", y+1, x+1)
|
||||
if _, err := io.WriteString(tty, posSeq); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tty.Write(r.sixelData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := io.WriteString(tty, "\x1b[u"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// openTTY opens the active terminal output device directly, bypassing tcell text buffers.
|
||||
func openTTY() (io.WriteCloser, error) {
|
||||
if runtime.GOOS == "windows" {
|
||||
return os.OpenFile("CONOUT$", os.O_WRONLY, 0)
|
||||
}
|
||||
return os.OpenFile("/dev/tty", os.O_WRONLY, 0)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package sixel_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/nebara/nebara-os/internal/ui/sixel"
|
||||
)
|
||||
|
||||
func TestNewLogoRenderer(t *testing.T) {
|
||||
r, err := sixel.NewLogoRenderer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewLogoRenderer failed: %v", err)
|
||||
}
|
||||
|
||||
if r.SixelDataLength() == 0 {
|
||||
t.Error("SIXEL data should not be empty")
|
||||
}
|
||||
|
||||
if r.CellCols() != sixel.DefaultLogoCellWidth {
|
||||
t.Errorf("expected CellCols %d, got %d", sixel.DefaultLogoCellWidth, r.CellCols())
|
||||
}
|
||||
|
||||
if r.CellRows() != sixel.DefaultLogoCellHeight {
|
||||
t.Errorf("expected CellRows %d, got %d", sixel.DefaultLogoCellHeight, r.CellRows())
|
||||
}
|
||||
|
||||
if !r.IsEnabled() {
|
||||
t.Error("Renderer should be enabled by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestToggleEnabled(t *testing.T) {
|
||||
r, err := sixel.NewLogoRenderer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewLogoRenderer failed: %v", err)
|
||||
}
|
||||
|
||||
r.SetEnabled(false)
|
||||
if r.IsEnabled() {
|
||||
t.Error("Renderer should be disabled")
|
||||
}
|
||||
|
||||
r.SetEnabled(true)
|
||||
if !r.IsEnabled() {
|
||||
t.Error("Renderer should be re-enabled")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDraw_SimulationScreenLock(t *testing.T) {
|
||||
r, err := sixel.NewLogoRenderer()
|
||||
if err != nil {
|
||||
t.Fatalf("NewLogoRenderer failed: %v", err)
|
||||
}
|
||||
|
||||
sim := tcell.NewSimulationScreen("")
|
||||
if err := sim.Init(); err != nil {
|
||||
t.Fatalf("simScreen init failed: %v", err)
|
||||
}
|
||||
defer sim.Fini()
|
||||
|
||||
sim.SetSize(120, 40)
|
||||
|
||||
// In test environment, emitSixel may fail if CONOUT$ or /dev/tty is unavailable in CI/headless,
|
||||
// but LockRegion must be executed on the screen.
|
||||
_ = r.Draw(sim, 4, 1)
|
||||
|
||||
// Invalidate and verify
|
||||
r.Invalidate()
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// 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