Skip to content

Commit 4fb07c4

Browse files
authored
feat(tui): add OpenShell splash screen and rebrand title bar (#210)
* wip: feat(tui): OpenShell splash screen and branding * chore(tui): fix rustfmt import ordering --------- Co-authored-by: John Myers <johntmyers@users.noreply.github.com>
1 parent 2e51cca commit 4fb07c4

File tree

4 files changed

+208
-2
lines changed

4 files changed

+208
-2
lines changed

crates/navigator-tui/src/app.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ use tonic::transport::Channel;
1515
/// Top-level screen (each is a full-screen layout with its own nav bar).
1616
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1717
pub enum Screen {
18+
/// Splash / boot screen shown on startup.
19+
Splash,
1820
/// Cluster list + provider list + sandbox table.
1921
Dashboard,
2022
/// Single-sandbox view (detail + logs).
@@ -271,6 +273,9 @@ pub struct App {
271273
pub focus: Focus,
272274
pub command_input: String,
273275

276+
/// When the splash screen was shown (for auto-dismiss timing).
277+
pub splash_start: Option<Instant>,
278+
274279
// Active cluster connection
275280
pub cluster_name: String,
276281
pub endpoint: String,
@@ -354,10 +359,11 @@ impl App {
354359
pub fn new(client: NavigatorClient<Channel>, cluster_name: String, endpoint: String) -> Self {
355360
Self {
356361
running: true,
357-
screen: Screen::Dashboard,
362+
screen: Screen::Splash,
358363
input_mode: InputMode::Normal,
359364
focus: Focus::Clusters,
360365
command_input: String::new(),
366+
splash_start: Some(Instant::now()),
361367
cluster_name,
362368
endpoint,
363369
client,
@@ -433,12 +439,26 @@ impl App {
433439
// Key handling
434440
// ------------------------------------------------------------------
435441

442+
/// Dismiss the splash screen and transition to the dashboard.
443+
pub fn dismiss_splash(&mut self) {
444+
if self.screen == Screen::Splash {
445+
self.screen = Screen::Dashboard;
446+
self.splash_start = None;
447+
}
448+
}
449+
436450
pub fn handle_key(&mut self, key: KeyEvent) {
437451
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('c') {
438452
self.running = false;
439453
return;
440454
}
441455

456+
// Splash screen: any key dismisses.
457+
if self.screen == Screen::Splash {
458+
self.dismiss_splash();
459+
return;
460+
}
461+
442462
// Modals intercept all keys when open.
443463
if self.create_form.is_some() {
444464
self.handle_create_form_key(key);

crates/navigator-tui/src/lib.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity
2525
use app::{App, ClusterEntry, Focus, LogLine, Screen};
2626
use event::{Event, EventHandler};
2727

28+
/// Duration to show the splash screen before auto-dismissing.
29+
const SPLASH_DURATION: Duration = Duration::from_secs(3);
30+
2831
/// Launch the OpenShell TUI.
2932
///
3033
/// `channel` must be a connected gRPC channel to the OpenShell gateway.
@@ -187,6 +190,15 @@ pub async fn run(channel: Channel, cluster_name: &str, endpoint: &str) -> Result
187190
_ => {}
188191
},
189192
Some(Event::Tick) => {
193+
// Auto-dismiss splash after SPLASH_DURATION.
194+
if app.screen == Screen::Splash {
195+
if let Some(start) = app.splash_start {
196+
if start.elapsed() >= SPLASH_DURATION {
197+
app.dismiss_splash();
198+
}
199+
}
200+
}
201+
190202
refresh_cluster_list(&mut app);
191203
refresh_data(&mut app).await;
192204

crates/navigator-tui/src/ui/mod.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ pub(crate) mod sandbox_detail;
99
pub(crate) mod sandbox_logs;
1010
mod sandbox_policy;
1111
pub(crate) mod sandboxes;
12+
mod splash;
1213

1314
use ratatui::Frame;
1415
use ratatui::layout::{Constraint, Direction, Layout, Rect};
@@ -19,6 +20,12 @@ use crate::app::{self, App, Focus, InputMode, Screen};
1920
use crate::theme::styles;
2021

2122
pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
23+
// Splash screen is a full-screen takeover — no chrome.
24+
if app.screen == Screen::Splash {
25+
splash::draw(frame, frame.size());
26+
return;
27+
}
28+
2229
let chunks = Layout::default()
2330
.direction(Direction::Vertical)
2431
.constraints([
@@ -32,6 +39,7 @@ pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
3239
draw_title_bar(frame, app, chunks[0]);
3340

3441
match app.screen {
42+
Screen::Splash => unreachable!(),
3543
Screen::Dashboard => dashboard::draw(frame, app, chunks[1]),
3644
Screen::Sandbox => draw_sandbox_screen(frame, app, chunks[1]),
3745
}
@@ -98,7 +106,14 @@ fn draw_title_bar(frame: &mut Frame<'_>, app: &App, area: Rect) {
98106
};
99107

100108
let mut parts: Vec<Span<'_>> = vec![
101-
Span::styled(" OpenShell", styles::ACCENT_BOLD),
109+
Span::styled(" >_ OpenShell ", styles::ACCENT_BOLD),
110+
Span::styled(
111+
" ALPHA ",
112+
ratatui::style::Style::new()
113+
.fg(crate::theme::colors::BG)
114+
.bg(crate::theme::colors::NVIDIA_GREEN)
115+
.add_modifier(ratatui::style::Modifier::BOLD),
116+
),
102117
Span::styled(" | ", styles::MUTED),
103118
Span::styled("Current Cluster: ", styles::TEXT),
104119
Span::styled(&app.cluster_name, styles::HEADING),
@@ -109,6 +124,7 @@ fn draw_title_bar(frame: &mut Frame<'_>, app: &App, area: Rect) {
109124
];
110125

111126
match app.screen {
127+
Screen::Splash => unreachable!("splash handled before draw_title_bar"),
112128
Screen::Dashboard => {
113129
parts.push(Span::styled("Dashboard", styles::TEXT));
114130
}
@@ -128,6 +144,7 @@ fn draw_title_bar(frame: &mut Frame<'_>, app: &App, area: Rect) {
128144

129145
fn draw_nav_bar(frame: &mut Frame<'_>, app: &App, area: Rect) {
130146
let spans = match app.screen {
147+
Screen::Splash => unreachable!("splash handled before draw_nav_bar"),
131148
Screen::Dashboard => match app.focus {
132149
Focus::Providers => vec![
133150
Span::styled(" ", styles::TEXT),
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! Splash screen shown on TUI startup — the "B2 Framed" design.
5+
//!
6+
//! Renders the ANSI Shadow block-letter logo (OPEN in white, SHELL in green)
7+
//! inside a double-line border, with the tagline and a version/prompt footer.
8+
9+
use ratatui::Frame;
10+
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
11+
use ratatui::style::{Modifier, Style};
12+
use ratatui::text::{Line, Span};
13+
use ratatui::widgets::{Block, BorderType, Borders, Clear, Padding, Paragraph};
14+
15+
use crate::theme::{colors, styles};
16+
17+
// ---------------------------------------------------------------------------
18+
// ANSI Shadow figlet art — OPEN (6 lines, 35 display cols)
19+
// ---------------------------------------------------------------------------
20+
21+
const OPEN_ART: [&str; 6] = [
22+
" ██████╗ ██████╗ ███████╗███╗ ██╗",
23+
"██╔═══██╗██╔══██╗██╔════╝████╗ ██║",
24+
"██║ ██║██████╔╝█████╗ ██╔██╗ ██║",
25+
"██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║",
26+
"╚██████╔╝██║ ███████╗██║ ╚████║",
27+
" ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝",
28+
];
29+
30+
// ---------------------------------------------------------------------------
31+
// ANSI Shadow figlet art — SHELL (6 lines, 40 display cols)
32+
// ---------------------------------------------------------------------------
33+
34+
const SHELL_ART: [&str; 6] = [
35+
"███████╗██╗ ██╗███████╗██╗ ██╗",
36+
"██╔════╝██║ ██║██╔════╝██║ ██║",
37+
"███████╗███████║█████╗ ██║ ██║",
38+
"╚════██║██╔══██║██╔══╝ ██║ ██║",
39+
"███████║██║ ██║███████╗███████╗███████╗",
40+
"╚══════╝╚═╝ ╚═╝╚══════╝╚══════╝╚══════╝",
41+
];
42+
43+
const TAGLINE: &str = "Shells Wide Shut.";
44+
45+
/// Maximum display width of the art (SHELL line 5 is the widest at 40 cols).
46+
const ART_WIDTH: u16 = 40;
47+
48+
/// Total content lines: 6 (OPEN) + 6 (SHELL) + 1 (blank) + 1 (tagline) = 14.
49+
const CONTENT_LINES: u16 = 14;
50+
51+
// Border (2) + top/bottom inner padding (2) + content + blank before footer (1) + footer (1).
52+
const MODAL_HEIGHT: u16 = CONTENT_LINES + 6;
53+
54+
// Art width + left/right padding (3+3) + borders (2).
55+
const MODAL_WIDTH: u16 = ART_WIDTH + 8;
56+
57+
/// Draw the splash screen centered on the full terminal area.
58+
pub fn draw(frame: &mut Frame<'_>, area: Rect) {
59+
let modal_w = MODAL_WIDTH.min(area.width.saturating_sub(2));
60+
let modal_h = MODAL_HEIGHT.min(area.height.saturating_sub(2));
61+
62+
// Center the modal.
63+
let popup = centered_rect(modal_w, modal_h, area);
64+
65+
// Clear the area behind the modal.
66+
frame.render_widget(Clear, popup);
67+
68+
// Outer double-line border.
69+
let block = Block::default()
70+
.borders(Borders::ALL)
71+
.border_type(BorderType::Double)
72+
.border_style(Style::new().fg(colors::EVERGLADE))
73+
.padding(Padding::new(3, 3, 1, 1));
74+
75+
let inner = block.inner(popup);
76+
frame.render_widget(block, popup);
77+
78+
// Split inner area: art content + spacer + footer.
79+
let chunks = Layout::default()
80+
.direction(Direction::Vertical)
81+
.constraints([
82+
Constraint::Length(CONTENT_LINES), // OPEN + SHELL + blank + tagline
83+
Constraint::Min(0), // spacer
84+
Constraint::Length(1), // footer
85+
])
86+
.split(inner);
87+
88+
// -- Art + tagline --
89+
let open_style = Style::new().fg(colors::FG).add_modifier(Modifier::BOLD);
90+
let shell_style = Style::new()
91+
.fg(colors::NVIDIA_GREEN)
92+
.add_modifier(Modifier::BOLD);
93+
94+
let mut content_lines: Vec<Line<'_>> = Vec::with_capacity(CONTENT_LINES as usize);
95+
96+
for line in &OPEN_ART {
97+
content_lines.push(Line::from(Span::styled(*line, open_style)));
98+
}
99+
for line in &SHELL_ART {
100+
content_lines.push(Line::from(Span::styled(*line, shell_style)));
101+
}
102+
103+
// Blank + tagline.
104+
content_lines.push(Line::from(""));
105+
content_lines
106+
.push(Line::from(Span::styled(TAGLINE, styles::MUTED)).alignment(Alignment::Center));
107+
108+
frame.render_widget(Paragraph::new(content_lines), chunks[0]);
109+
110+
// -- Footer: version + ALPHA badge (left) + prompt (right) --
111+
let version = format!("v{}", env!("CARGO_PKG_VERSION"));
112+
let spacer = " ";
113+
let alpha_badge = "ALPHA";
114+
let prompt_text = "press any key";
115+
116+
// Pad between left group and prompt to fill the line.
117+
let used = version.len() + spacer.len() + alpha_badge.len() + prompt_text.len() + 2; // +2 for ░ and space
118+
let footer_width = chunks[2].width as usize;
119+
let gap = footer_width.saturating_sub(used);
120+
121+
let footer = Line::from(vec![
122+
Span::styled(version, Style::new().fg(colors::NVIDIA_GREEN)),
123+
Span::styled(spacer, styles::MUTED),
124+
Span::styled(
125+
alpha_badge,
126+
Style::new()
127+
.fg(colors::FG)
128+
.bg(colors::EVERGLADE)
129+
.add_modifier(Modifier::BOLD),
130+
),
131+
Span::styled(" ".repeat(gap), styles::MUTED),
132+
Span::styled(prompt_text, styles::MUTED),
133+
Span::styled(" ░", styles::MUTED),
134+
]);
135+
136+
frame.render_widget(Paragraph::new(footer), chunks[2]);
137+
}
138+
139+
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
140+
let vert = Layout::default()
141+
.direction(Direction::Vertical)
142+
.constraints([
143+
Constraint::Length((area.height.saturating_sub(height)) / 2),
144+
Constraint::Length(height),
145+
Constraint::Min(0),
146+
])
147+
.split(area);
148+
let horiz = Layout::default()
149+
.direction(Direction::Horizontal)
150+
.constraints([
151+
Constraint::Length((area.width.saturating_sub(width)) / 2),
152+
Constraint::Length(width),
153+
Constraint::Min(0),
154+
])
155+
.split(vert[1]);
156+
horiz[1]
157+
}

0 commit comments

Comments
 (0)