From 43b8bfa253cb3636d0b20be2331f51e770613238 Mon Sep 17 00:00:00 2001 From: Faiz Kaysan Hidayat Date: Tue, 13 Oct 2026 20:35:22 +0700 Subject: [PATCH] Commit: Initial Update --- README.md | 301 ++++++++++++++++++++ assets/logo.png | Bin 0 -> 2549 bytes assets/logo.sixel | 1 + cmd/nebara-tui/main.go | 43 +++ go.mod | 20 ++ go.sum | 58 ++++ internal/app/application.go | 332 +++++++++++++++++++++++ internal/app/application_test.go | 95 +++++++ internal/app/state.go | 30 ++ internal/mock/states.go | 48 ++++ internal/mock/states_test.go | 91 +++++++ internal/model/appliance.go | 12 + internal/model/menu.go | 82 ++++++ internal/model/message.go | 23 ++ internal/ui/components/header.go | 302 +++++++++++++++++++++ internal/ui/components/menu.go | 211 ++++++++++++++ internal/ui/layout/responsive.go | 93 +++++++ internal/ui/layout/responsive_test.go | 73 +++++ internal/ui/navigation/navigator.go | 127 +++++++++ internal/ui/navigation/navigator_test.go | 170 ++++++++++++ internal/ui/sixel/logo.png | Bin 0 -> 2549 bytes internal/ui/sixel/sixel.go | 188 +++++++++++++ internal/ui/sixel/sixel_test.go | 70 +++++ internal/ui/theme/colors.go | 70 +++++ 24 files changed, 2440 insertions(+) create mode 100644 README.md create mode 100644 assets/logo.png create mode 100644 assets/logo.sixel create mode 100644 cmd/nebara-tui/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/app/application.go create mode 100644 internal/app/application_test.go create mode 100644 internal/app/state.go create mode 100644 internal/mock/states.go create mode 100644 internal/mock/states_test.go create mode 100644 internal/model/appliance.go create mode 100644 internal/model/menu.go create mode 100644 internal/model/message.go create mode 100644 internal/ui/components/header.go create mode 100644 internal/ui/components/menu.go create mode 100644 internal/ui/layout/responsive.go create mode 100644 internal/ui/layout/responsive_test.go create mode 100644 internal/ui/navigation/navigator.go create mode 100644 internal/ui/navigation/navigator_test.go create mode 100644 internal/ui/sixel/logo.png create mode 100644 internal/ui/sixel/sixel.go create mode 100644 internal/ui/sixel/sixel_test.go create mode 100644 internal/ui/theme/colors.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..92acfd0 --- /dev/null +++ b/README.md @@ -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 +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* diff --git a/assets/logo.png b/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..9297015a80f3c5790546c8c3f381597e121f63b4 GIT binary patch literal 2549 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 LNkvXXu0mjfhdt-_ literal 0 HcmV?d00001 diff --git a/assets/logo.sixel b/assets/logo.sixel new file mode 100644 index 0000000..651c699 --- /dev/null +++ b/assets/logo.sixel @@ -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?@\ \ No newline at end of file diff --git a/cmd/nebara-tui/main.go b/cmd/nebara-tui/main.go new file mode 100644 index 0000000..a6224ac --- /dev/null +++ b/cmd/nebara-tui/main.go @@ -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) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..17ab44a --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..12407a2 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/app/application.go b/internal/app/application.go new file mode 100644 index 0000000..925fdc7 --- /dev/null +++ b/internal/app/application.go @@ -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() {} diff --git a/internal/app/application_test.go b/internal/app/application_test.go new file mode 100644 index 0000000..209693a --- /dev/null +++ b/internal/app/application_test.go @@ -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") + } +} diff --git a/internal/app/state.go b/internal/app/state.go new file mode 100644 index 0000000..efde5e2 --- /dev/null +++ b/internal/app/state.go @@ -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, + } +} diff --git a/internal/mock/states.go b/internal/mock/states.go new file mode 100644 index 0000000..9d9481c --- /dev/null +++ b/internal/mock/states.go @@ -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 + } +} diff --git a/internal/mock/states_test.go b/internal/mock/states_test.go new file mode 100644 index 0000000..55fbc68 --- /dev/null +++ b/internal/mock/states_test.go @@ -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") + } +} diff --git a/internal/model/appliance.go b/internal/model/appliance.go new file mode 100644 index 0000000..60cb53c --- /dev/null +++ b/internal/model/appliance.go @@ -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 +} diff --git a/internal/model/menu.go b/internal/model/menu.go new file mode 100644 index 0000000..b503e45 --- /dev/null +++ b/internal/model/menu.go @@ -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."}, + }, + }, + } +} diff --git a/internal/model/message.go b/internal/model/message.go new file mode 100644 index 0000000..5847089 --- /dev/null +++ b/internal/model/message.go @@ -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 != "" +} diff --git a/internal/ui/components/header.go b/internal/ui/components/header.go new file mode 100644 index 0000000..5a5bfac --- /dev/null +++ b/internal/ui/components/header.go @@ -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) + } +} diff --git a/internal/ui/components/menu.go b/internal/ui/components/menu.go new file mode 100644 index 0000000..f4d4a97 --- /dev/null +++ b/internal/ui/components/menu.go @@ -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) + } +} diff --git a/internal/ui/layout/responsive.go b/internal/ui/layout/responsive.go new file mode 100644 index 0000000..d14b318 --- /dev/null +++ b/internal/ui/layout/responsive.go @@ -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 +} diff --git a/internal/ui/layout/responsive_test.go b/internal/ui/layout/responsive_test.go new file mode 100644 index 0000000..f80b188 --- /dev/null +++ b/internal/ui/layout/responsive_test.go @@ -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) + } +} diff --git a/internal/ui/navigation/navigator.go b/internal/ui/navigation/navigator.go new file mode 100644 index 0000000..9f1ae41 --- /dev/null +++ b/internal/ui/navigation/navigator.go @@ -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 +} diff --git a/internal/ui/navigation/navigator_test.go b/internal/ui/navigation/navigator_test.go new file mode 100644 index 0000000..ff8fe54 --- /dev/null +++ b/internal/ui/navigation/navigator_test.go @@ -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()) + } +} diff --git a/internal/ui/sixel/logo.png b/internal/ui/sixel/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..9297015a80f3c5790546c8c3f381597e121f63b4 GIT binary patch literal 2549 zcmV)#m6vtl=P@xK_3@c1F*e3u^fLRk_`vfs3z?{J51cnm;C&1ujjdKFv1UM_4%7RG| zDpX;Q^8d+t;~rVk=wtkqimIR0>VEzDeRr!h?j=d`EZK*LHib*H76i~dL{IIj0{XhZ zdd#Nib*0I8c<#q^o7Ba_tRR-DF)fXUhnPl_Ix;*IjXO>F&f{u)XF2jTkNWxbl^+&f zUz&NgdHLGgU#`r2*t{~6Oo9~e^2{5?y}mqCsWa*1j$mEzQt(0WNl>?d|3=7+zu~&* zsj{5G6FODzT5gE|G#7Qxmw7eN*9G!)yV6_}PaA@_lK*Gj+muxeie=(iO^u4{Ev8eq zjtoyU+m>X&(1#M9`sx%CT0CbPIqJ;Q8Ruoc-``Z=1RHLIo-JDlw-U2it8t}x!R8qk zf^EINd=0rRe@U0fzoqsS0e%PlzUp-g`1mK|w?!`vaDpx-u%Zr}Zxoer=M?MDMeA-$ zSkkzn(v~Ll2s#u*;%TB(lfDSl<%}V}qsLUiS$mV)J0U-42pE;1+txUjqtX1C95|Lp zSS-g)JB8PUy^HInz%dI0OKffTo6X(-yc6m|Z@70mnBg-PLRi_nbZsnANgwEuWM;b1 zE&h5{xQ&X&-WFgN-3&Sw2OWl^lZ;EoJj%e^7Ux?n0jIQrt1V3e4ksUoCIPpgdz0dZ zS(7|3G%!`H?%u9yqcRYk=MCYh@E>Uw42&GA8g{e>m-Fj#qztD7kjj_z0{$P5_hf@T9!bAj0@rPfS6SB> z{59oOh_@8XH{zgsuSTL7=85umLTKuMB60SL6oWF68|Dc-?h`@!Av>Zg#=4U})^Ie(Ef@x-Sh_ApJzwq#G77K5=GR?#`> zKIDK;>|Q1Z`Z+angkbPv$B1A1_BX%f{#I+yO9N(kX@*oSwAg3c)m zfp-Mo=W{T?(PzB;I_hb;_Z2)h8p2KCvju#U3Gqa7lZ~>+;+w``2;ZmkJ~)Ob8W_?0 z(cN+C4c_1f9=Ml+hNlj54hQmSq{7AZ`P`B8jcB!d8#W*96#}@GgcjIlLgzj1et)@E~POVUS99 zziBLeDQ(q{v`C*g2dfr#VlL+9UIqUCha8^S$M7Wws~@zG!*@SpIA{IASIi_^L6Jvt zV?TU@M-mghlK)1>=b19#D**n@_q|owXLOoe8Fb91$zSQtb`xhNixPNbNHw=E(7`#{ z0LCwx82)gM99Y#eiZor&BoUw>>S`)3QXVTK{Da`D0QDm9ehyVeN-si;+Q2O{WLf(s zjR$-_R&4C1=Hk*)jzx{DD@XCfXQ)-a&n||5BRSzKEIzRW_(2)4IN;wbTG)lSNng_@ z1|2hKavYii7Vb>K$!3>5J{faPwA%o}Yv`duw)TB<-TQ6H7DNffqEHmXYN4jXMGi+m zqCh%FhbKG~foPL0k(F-Gn$#x&I&K2+g>7&SWUpMYB?q*d%HjOGJd(`w8vV)9Y z1*a~cWy2SOB>}!N`c5a|t_rIBmv{mA4(z()RpU;rpc8S^q!^VI55v;L$y}*}f=~f` z`{2dB4Gn1Hnva%9D`Z$aAN9tAkpBk=)x75$H_mlMTcj8VpT_9tjtx$%=dxM zoFpBb!=Gs@gOMDP9sbJj;&$+mgKtfYkNfJ>$@&C(6@XKR)>Lsq&#@fo`i+9lsmLkFdTwl12wob`^vRZF ziVa!hP&l2dfc-jK(z-yvG8Vcq9ojm9_+|^s>Qe8HI_3=_5O{0{a8?-PMX~Dv3#3m3l+|Jb z`f+i}i~2Y`!2mq7(z91@3O=3{Y1;2DtRCh`p1L?5`TZX8GYW z!nB3>zYKQIf8D3MPs{@l-`;QO_Lrmxg~K1mX{`$(1hOpedF+K@4yN6xb;+C-JpZW^ zTf6-6xv%PEY4+&s)tdtEjjTw>>-4C1h7bb!_jk-H+TJ%UIa(d6bQO5-{6(#ZLCHS! z&+UHKY3C5kTqj#ggTcTKF1Hn*Vmc*BB|BonlY05b@q@XmYnj2x`UD)vPMmrfa4cSR ziZ!XPD4VyX$-e!q7TSTs!z-(28yaO}*m>YI90VP563MZu zfkz>SCo7RZp0YI99@x-QUCRGf_!pneOV=N=YSn@(*A~F%|LQE*yy@zF3%(Q4lbPsl zo0$h5KZnFm?nsYKfcHZDhwr-R9{X&y-dQY z{AN;*qw>9K1R?w~j~+W0r-!bf?*uUr^AzdLafcX!YIGxyWyKVB6vA)nw?V@`ee z$eQlZ$4(RGF#5p*pK*-GzqBnj`xgKJ|No*5$Rq#&00v1!K~w_(L8bft=QJ$P00000 LNkvXXu0mjfhdt-_ literal 0 HcmV?d00001 diff --git a/internal/ui/sixel/sixel.go b/internal/ui/sixel/sixel.go new file mode 100644 index 0000000..b3d5db6 --- /dev/null +++ b/internal/ui/sixel/sixel.go @@ -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) +} diff --git a/internal/ui/sixel/sixel_test.go b/internal/ui/sixel/sixel_test.go new file mode 100644 index 0000000..6b643a4 --- /dev/null +++ b/internal/ui/sixel/sixel_test.go @@ -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() +} diff --git a/internal/ui/theme/colors.go b/internal/ui/theme/colors.go new file mode 100644 index 0000000..924b645 --- /dev/null +++ b/internal/ui/theme/colors.go @@ -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)