use std::io::{self, Write};
use crossterm::cursor::MoveTo;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
use crossterm::style::{
Color as CtColor, Print, ResetColor, SetBackgroundColor, SetForegroundColor,
};
use crossterm::{queue, terminal};
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::Paragraph;
use ratatui::Terminal;
use newt_core::agentic::NEWT_ORANGE_CT;
use crate::{
brand_active, brand_logo, brand_name, brand_plugins, brand_tagline, logo_for_size, LOGO_PLAIN,
NEWT_ORANGE, VERSION,
};
fn row_is_blank(row: &str) -> bool {
const INK: u32 = 56;
let mut hay = row;
while let Some(i) = hay.find("8;2;") {
let mut nums = hay[i + 4..]
.split(|c: char| !c.is_ascii_digit())
.filter(|s| !s.is_empty())
.map(|s| s.parse::<u32>().unwrap_or(0));
let r = nums.next().unwrap_or(0);
let g = nums.next().unwrap_or(0);
let b = nums.next().unwrap_or(0);
if r.max(g).max(b) >= INK {
return false;
}
hay = &hay[i + 4..];
}
true
}
fn blank_band(rows: &[&str], need: usize) -> Option<(usize, usize)> {
if need == 0 || rows.is_empty() {
return None;
}
let n = rows.len();
let mut bottom = n;
while bottom > 0 && row_is_blank(rows[bottom - 1]) {
bottom -= 1;
}
let bottom_h = n - bottom;
let mut top = 0;
while top < n && row_is_blank(rows[top]) {
top += 1;
}
let top_h = top;
if bottom_h >= need && bottom_h >= top_h {
Some((bottom, n))
} else if top_h >= need {
Some((0, top))
} else if bottom_h >= need {
Some((bottom, n))
} else {
None
}
}
fn splash_block() -> Vec<Vec<(String, Option<CtColor>)>> {
let mut block = vec![
vec![
(brand_name(), Some(NEWT_ORANGE_CT)),
(format!(" · {}", brand_tagline()), None),
],
vec![(format!("v{VERSION}"), Some(CtColor::DarkGrey))],
];
if let Some(plugins) = brand_plugins() {
block.push(vec![(plugins, Some(CtColor::DarkGrey))]);
}
block.push(vec![(
"Enter start coder · q quit".to_string(),
Some(CtColor::DarkGrey),
)]);
block
}
pub(crate) fn show_splash(
out: &mut io::Stdout,
workspace: &str,
color: bool,
) -> anyhow::Result<bool> {
if color {
show_splash_color(out, workspace)
} else {
show_splash_plain(out, workspace)
}
}
fn show_splash_color(out: &mut io::Stdout, _workspace: &str) -> anyhow::Result<bool> {
let (term_cols, term_rows) = terminal::size().unwrap_or((80, 24));
let (logo, logo_cols) = logo_for_size(term_cols, term_rows);
let logo_lines: Vec<&str> = logo.lines().collect();
let logo_rows = logo_lines.len() as u16;
write!(out, "{}", logo.replace('\n', "\r\n"))?;
out.flush()?;
let block = splash_block();
if let Some((start, end)) = brand_active()
.then(|| blank_band(&logo_lines, block.len()))
.flatten()
{
let dark = CtColor::Rgb {
r: 20,
g: 20,
b: 20,
};
let top = start + (end - start - block.len()) / 2;
for (k, line) in block.iter().enumerate() {
let width: usize = line.iter().map(|(t, _)| t.chars().count()).sum();
let col = (logo_cols as usize).saturating_sub(width) / 2;
queue!(
out,
MoveTo(col as u16, (top + k) as u16),
SetBackgroundColor(dark)
)?;
for (text, color) in line {
queue!(out, SetForegroundColor(color.unwrap_or(CtColor::Reset)))?;
queue!(out, Print(text))?;
}
queue!(out, ResetColor)?;
}
out.flush()?;
return splash_wait_for_continue();
}
let brand_col = logo_cols + 2;
let brand_row = logo_rows.saturating_sub(4) / 2;
let tagline = brand_tagline();
queue!(out, MoveTo(brand_col, brand_row))?;
queue!(
out,
SetForegroundColor(NEWT_ORANGE_CT),
Print(brand_name()),
ResetColor,
Print(format!(" · {tagline}"))
)?;
queue!(out, MoveTo(brand_col, brand_row + 1))?;
queue!(
out,
SetForegroundColor(CtColor::DarkGrey),
Print(format!("v{VERSION}")),
ResetColor
)?;
if let Some(plugins) = brand_plugins() {
queue!(out, MoveTo(brand_col, brand_row + 2))?;
queue!(
out,
SetForegroundColor(CtColor::DarkGrey),
Print(plugins),
ResetColor
)?;
}
queue!(out, MoveTo(brand_col, brand_row + 3))?;
queue!(
out,
SetForegroundColor(CtColor::DarkGrey),
Print("Enter start coder · q quit"),
ResetColor
)?;
out.flush()?;
splash_wait_for_continue()
}
fn show_splash_plain(_out: &mut io::Stdout, workspace: &str) -> anyhow::Result<bool> {
let backend = CrosstermBackend::new(io::stdout());
let mut terminal = Terminal::new(backend)?;
let mut polls: u32 = 0;
let result = loop {
terminal.draw(|f| {
let area = f.area();
let orange_bold = Style::default()
.fg(NEWT_ORANGE)
.add_modifier(Modifier::BOLD);
let dim = Style::default().fg(Color::DarkGray);
let mut lines: Vec<Line> = vec![Line::from("")];
let logo = brand_logo(LOGO_PLAIN, "ascii-40");
for l in logo.lines() {
lines.push(Line::from(l.to_owned()));
}
lines.push(Line::from(""));
lines.push(Line::from(vec![
Span::styled(brand_name(), orange_bold),
Span::raw(format!(" · {}", brand_tagline())),
]));
lines.push(Line::from(Span::styled(format!("v{VERSION}"), dim)));
if let Some(plugins) = brand_plugins() {
lines.push(Line::from(Span::styled(plugins, dim)));
}
lines.push(Line::from(""));
lines.push(Line::from(format!("Workspace: {workspace}")));
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
"Enter start coder · q quit",
dim,
)));
let w = 60u16.min(area.width);
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Fill(1),
Constraint::Length(w),
Constraint::Fill(1),
])
.split(area);
f.render_widget(Paragraph::new(Text::from(lines)), cols[1]);
})?;
if let Some(cont) = splash_poll_event()? {
break cont;
}
polls += 1;
if polls >= SPLASH_AUTO_CONTINUE_POLLS {
break true;
}
};
Ok(result)
}
const SPLASH_AUTO_CONTINUE_POLLS: u32 = 30;
fn splash_poll_event() -> anyhow::Result<Option<bool>> {
if event::poll(std::time::Duration::from_millis(100))? {
return Ok(Some(splash_key_action(&event::read()?)));
}
Ok(None)
}
fn splash_wait_for_continue() -> anyhow::Result<bool> {
for _ in 0..SPLASH_AUTO_CONTINUE_POLLS {
if event::poll(std::time::Duration::from_millis(100))? {
return Ok(splash_key_action(&event::read()?));
}
}
Ok(true)
}
fn splash_key_action(ev: &Event) -> bool {
match ev {
Event::Key(KeyEvent {
code: KeyCode::Char('q'),
..
}) => false,
Event::Key(KeyEvent {
code: KeyCode::Esc, ..
}) => false,
Event::Key(KeyEvent {
code: KeyCode::Char('c'),
modifiers,
..
}) if modifiers.contains(KeyModifiers::CONTROL) => false,
Event::Key(KeyEvent {
code: KeyCode::Enter | KeyCode::Char(_),
..
}) => true,
_ => true, }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn row_is_blank_distinguishes_dark_fill_from_ink() {
let dark = "\x1b[38;2;20;20;20m\x1b[48;2;18;18;18m▄\x1b[0m";
let gold = "\x1b[38;2;235;195;70m\x1b[48;2;20;20;20m▄\x1b[0m";
assert!(row_is_blank(dark), "all-dark row is blank");
assert!(!row_is_blank(gold), "a gold cell is ink");
assert!(row_is_blank(""), "empty row is blank");
}
#[test]
fn blank_band_prefers_bottom_and_respects_need() {
let dark = "\x1b[38;2;20;20;20m\x1b[48;2;20;20;20m▄";
let ink = "\x1b[38;2;235;195;70m▄";
let rows = [dark, dark, ink, ink, dark, dark, dark];
assert_eq!(blank_band(&rows, 3), Some((4, 7)), "bottom band fits 3");
assert_eq!(
blank_band(&rows, 2),
Some((4, 7)),
"bottom preferred over top"
);
assert_eq!(blank_band(&rows, 4), None, "neither band holds 4");
assert_eq!(blank_band(&rows, 0), None);
let top_heavy = [dark, dark, dark, ink, ink];
assert_eq!(blank_band(&top_heavy, 3), Some((0, 3)));
}
#[test]
fn splash_key_action_quit_keys() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
assert!(!splash_key_action(&Event::Key(KeyEvent::new(
KeyCode::Char('q'),
KeyModifiers::NONE
))));
assert!(!splash_key_action(&Event::Key(KeyEvent::new(
KeyCode::Esc,
KeyModifiers::NONE
))));
assert!(!splash_key_action(&Event::Key(KeyEvent::new(
KeyCode::Char('c'),
KeyModifiers::CONTROL
))));
}
#[test]
fn splash_key_action_continue_keys() {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
assert!(splash_key_action(&Event::Key(KeyEvent::new(
KeyCode::Enter,
KeyModifiers::NONE
))));
assert!(splash_key_action(&Event::Key(KeyEvent::new(
KeyCode::Char('h'),
KeyModifiers::NONE
))));
}
}