use ratatui::crossterm::event::{Event as CrosstermEvent, KeyCode, KeyEventKind};
use ratatui::{prelude::*, widgets::*};
use std::time::{SystemTime, UNIX_EPOCH};
use crate::app::{AppMode, AppState};
use crate::theme::{blend_colors, color_to_rgb, ThemeContext};
use crate::tui::screen_context::ScreenContext;
const WELCOME_LICENSE_LABEL: &str = "GNU General Public License v3.0";
const LOGO_LARGE: &str = r#"
__
/\ \
____ __ __ _____ __ _ __ ____ __ __ \_\ \ _ __
/',__\/\ \/\ \/\ '__`\ /'__`\/\`'__\/',__\ /'__`\ /'__`\ /'_` \/\`'__\
/\__, `\ \ \_\ \ \ \L\ \/\ __/\ \ \//\__, `\/\ __//\ __//\ \L\ \ \ \/
\/\____/\ \____/\ \ ,__/\ \____\\ \_\\/\____/\ \____\ \____\ \___,_\ \_\
\/___/ \/___/ \ \ \/ \/____/ \/_/ \/___/ \/____/\/____/\/__,_ /\/_/
\ \_\
\/_/
"#;
const LOGO_MEDIUM: &str = r#"
__
/\ \
____ __ __ \_\ \ _ __
/',__\ /'__`\ /'__`\ /'_` \/\`'__\
/\__, `\/\ __//\ __//\ \L\ \ \ \/
\/\____/\ \____\ \____\ \___,_\ \_\
\/___/ \/____/\/____/\/__,_ /\/_/
"#;
const LOGO_SMALL: &str = r#"
____ ____
/',__\ /',__\
/\__, `\/\__, `\
\/\____/\/\____/
\/___/ \/___/
"#;
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum WelcomeAction {
Dismiss,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum WelcomeEffect {
ToNormal,
}
#[derive(Default)]
pub struct WelcomeReduceResult {
pub consumed: bool,
pub effects: Vec<WelcomeEffect>,
}
fn map_key_to_welcome_action(key_code: KeyCode, key_kind: KeyEventKind) -> Option<WelcomeAction> {
if key_kind == KeyEventKind::Press && key_code == KeyCode::Esc {
return Some(WelcomeAction::Dismiss);
}
None
}
pub fn reduce_welcome_action(action: WelcomeAction) -> WelcomeReduceResult {
match action {
WelcomeAction::Dismiss => WelcomeReduceResult {
consumed: true,
effects: vec![WelcomeEffect::ToNormal],
},
}
}
pub fn execute_welcome_effects(app_state: &mut AppState, effects: Vec<WelcomeEffect>) {
for effect in effects {
match effect {
WelcomeEffect::ToNormal => app_state.mode = AppMode::Normal,
}
}
}
pub fn draw(f: &mut Frame, screen: &ScreenContext<'_>) {
let settings = screen.settings;
let ctx = screen.theme;
let area = f.area();
draw_background_dust(f, area, ctx);
let get_dims = |text: &str| -> (u16, u16) {
let h = text.lines().count() as u16;
let w = text.lines().map(|l| l.len()).max().unwrap_or(0) as u16;
(w, h)
};
let (w_large, h_large) = get_dims(LOGO_LARGE);
let (w_medium, h_medium) = get_dims(LOGO_MEDIUM);
let download_path_str = settings
.default_download_folder
.as_ref()
.map(|p| p.to_string_lossy())
.unwrap_or_else(|| std::borrow::Cow::Borrowed("Manual Selection"));
let text_lines = vec![
Line::from(Span::styled(
"How to Get Started:",
ctx.apply(Style::default().fg(ctx.state_warning()).bold()),
)),
Line::from(""),
Line::from(vec![
Span::styled(" โ
", ctx.apply(Style::default().fg(ctx.state_success()))),
Span::raw("Paste using "),
Span::styled(
"your terminal shortcut",
ctx.apply(Style::default().fg(ctx.accent_sky()).bold()),
),
Span::raw(" to add a "),
Span::styled(
"magnet link",
ctx.apply(Style::default().fg(ctx.accent_peach())),
),
Span::raw("."),
]),
Line::from(vec![
Span::raw(" - "),
Span::styled(
"e.g. \"magnet:?xt=urn:btih:...\"",
Style::default()
.fg(ctx.theme.semantic.surface2)
.add_modifier(Modifier::ITALIC),
),
]),
Line::from(vec![
Span::styled(" โ
", ctx.apply(Style::default().fg(ctx.state_success()))),
Span::raw("Press "),
Span::styled(
"[a]",
ctx.apply(Style::default().fg(ctx.state_selected()).bold()),
),
Span::raw(" to open the file picker and select a "),
Span::styled(
"`.torrent`",
ctx.apply(Style::default().fg(ctx.accent_peach())),
),
Span::raw(" file."),
]),
Line::from(vec![
Span::styled(" โ
", ctx.apply(Style::default().fg(ctx.state_success()))),
Span::raw("Use the "),
Span::styled(
"CLI",
ctx.apply(Style::default().fg(ctx.accent_sky()).bold()),
),
Span::raw(" from another terminal:"),
]),
Line::from(vec![
Span::raw(" - magnet: "),
Span::styled(
"superseedr add \"magnet:?xt=urn:btih:...\"",
ctx.apply(Style::default().fg(ctx.theme.semantic.surface2)),
),
]),
Line::from(vec![
Span::raw(" - file: "),
Span::styled(
"superseedr add \"/path/to/my.torrent\"",
ctx.apply(Style::default().fg(ctx.theme.semantic.surface2)),
),
]),
Line::from(vec![
Span::styled(" โ
", ctx.apply(Style::default().fg(ctx.state_success()))),
Span::raw("Drop files into your "),
Span::styled(
"Watch Folder",
ctx.apply(Style::default().fg(ctx.accent_sky()).bold()),
),
Span::raw(" to add them automatically."),
]),
Line::from(vec![
Span::styled(" โ
", ctx.apply(Style::default().fg(ctx.state_success()))),
Span::raw("Download Location: "),
Span::styled(
download_path_str,
ctx.apply(Style::default().fg(ctx.accent_sky()).bold()),
),
]),
Line::from(vec![
Span::raw(" - "),
Span::styled(
"Change or remove in Config [c]",
ctx.apply(Style::default().fg(ctx.theme.semantic.surface2))
.italic(),
),
]),
Line::from(""),
Line::from(vec![
Span::styled(
"Browser Support: ",
ctx.apply(Style::default().fg(ctx.state_warning()).bold()),
),
Span::raw("To open magnet links directly from your browser,"),
]),
Line::from(vec![
Span::raw(" natively install superseedr: "),
Span::styled(
"https://github.com/Jagalite/superseedr/releases",
Style::default().fg(ctx.state_info()).underlined(),
),
]),
];
let footer_line = Line::from(vec![
Span::styled(" [m] ", ctx.apply(Style::default().fg(ctx.accent_teal()))),
Span::styled(
"Manual/Help",
ctx.apply(Style::default().fg(ctx.theme.semantic.subtext1)),
),
Span::styled(
" | ",
ctx.apply(Style::default().fg(ctx.theme.semantic.surface2)),
),
Span::styled(
" [c] ",
ctx.apply(Style::default().fg(ctx.state_selected())),
),
Span::styled(
"Config",
ctx.apply(Style::default().fg(ctx.theme.semantic.subtext1)),
),
Span::styled(
" | ",
ctx.apply(Style::default().fg(ctx.theme.semantic.surface2)),
),
Span::styled(" [Q] ", ctx.apply(Style::default().fg(ctx.state_error()))),
Span::styled(
"Quit",
ctx.apply(Style::default().fg(ctx.theme.semantic.subtext1)),
),
Span::styled(
" | ",
ctx.apply(Style::default().fg(ctx.theme.semantic.surface2)),
),
Span::styled(" [Esc] ", ctx.apply(Style::default().fg(ctx.state_error()))),
Span::styled(
"Dismiss",
ctx.apply(Style::default().fg(ctx.theme.semantic.subtext1)),
),
]);
let text_content_height = text_lines.len() as u16;
let text_content_width = text_lines.iter().map(|l| l.width()).max().unwrap_or(0) as u16;
let footer_width = footer_line.width() as u16;
let box_vertical_gap = 1;
let box_horizontal_padding = 4;
let box_height_needed = text_content_height + box_vertical_gap + 1 + 2;
let gap_height = 1;
let available_height_for_logo = area
.height
.saturating_sub(box_height_needed + gap_height + 2);
let margin_x = 6;
let logo_text = if area.width >= (w_large + margin_x) && available_height_for_logo >= h_large {
LOGO_LARGE
} else if area.width >= (w_medium + margin_x) && available_height_for_logo >= h_medium {
LOGO_MEDIUM
} else {
LOGO_SMALL
};
let (logo_w, logo_h) = get_dims(logo_text);
let content_width_max = text_content_width
.max(footer_width)
.max(logo_w.min(text_content_width + 10));
let box_width = (content_width_max + box_horizontal_padding + 2).min(area.width);
let box_height = box_height_needed.min(area.height);
let vertical_chunks = Layout::vertical([
Constraint::Min(0),
Constraint::Length(logo_h),
Constraint::Length(gap_height),
Constraint::Length(box_height),
Constraint::Min(0),
])
.split(area);
let logo_area = vertical_chunks[1];
let box_area = vertical_chunks[3];
let logo_layout = Layout::horizontal([
Constraint::Min(0),
Constraint::Length(logo_w),
Constraint::Min(0),
])
.split(logo_area);
let box_layout = Layout::horizontal([
Constraint::Min(0),
Constraint::Length(box_width),
Constraint::Min(0),
])
.split(box_area);
let final_logo_area = logo_layout[1];
let final_box_area = box_layout[1];
let buf = f.buffer_mut();
for (y_local, line) in logo_text.lines().enumerate() {
if y_local >= final_logo_area.height as usize {
break;
}
let y_global = final_logo_area.y + y_local as u16;
for (x_local, c) in line.chars().enumerate() {
if x_local >= final_logo_area.width as usize {
break;
}
if c == ' ' {
continue;
}
let x_global = final_logo_area.x + x_local as u16;
let style = get_animated_style(ctx, x_local, y_local);
buf.set_string(x_global, y_global, c.to_string(), style);
}
}
f.render_widget(Clear, final_box_area);
let block = Block::default()
.borders(Borders::ALL)
.border_style(ctx.apply(Style::default().fg(ctx.theme.semantic.border)));
let inner_box = block.inner(final_box_area);
f.render_widget(block, final_box_area);
let box_internal_chunks = Layout::vertical([
Constraint::Length(text_content_height),
Constraint::Min(0),
Constraint::Length(1),
])
.split(inner_box);
let text_padding_layout = Layout::horizontal([
Constraint::Min(0),
Constraint::Length(text_content_width),
Constraint::Min(0),
])
.split(box_internal_chunks[0]);
let text_paragraph = Paragraph::new(text_lines)
.style(ctx.apply(Style::default().fg(ctx.theme.semantic.text)))
.alignment(Alignment::Left);
f.render_widget(text_paragraph, text_padding_layout[1]);
let footer_paragraph = Paragraph::new(footer_line).alignment(Alignment::Center);
f.render_widget(footer_paragraph, box_internal_chunks[2]);
let license_area = Rect::new(area.x, area.bottom().saturating_sub(1), area.width, 1);
let license_paragraph = Paragraph::new(WELCOME_LICENSE_LABEL)
.style(ctx.apply(Style::default().fg(ctx.theme.semantic.surface2)))
.alignment(Alignment::Center);
f.render_widget(license_paragraph, license_area);
}
pub fn handle_event(event: CrosstermEvent, app_state: &mut AppState) {
if let CrosstermEvent::Key(key) = event {
if let Some(action) = map_key_to_welcome_action(key.code, key.kind) {
let reduced = reduce_welcome_action(action);
if reduced.consumed {
execute_welcome_effects(app_state, reduced.effects);
}
}
}
}
fn get_animated_style(ctx: &ThemeContext, x: usize, y: usize) -> Style {
let time = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64();
let speed = 3.0;
let freq_x = 0.1;
let freq_y = 0.2;
let phase = (x as f64 * freq_x) + (y as f64 * freq_y) - (time * speed);
let ratio = (phase.sin() + 1.0) / 2.0;
let color_blue = color_to_rgb(ctx.theme.scale.stream.inflow);
let color_green = color_to_rgb(ctx.theme.scale.stream.outflow);
let base_color = blend_colors(color_blue, color_green, ratio);
let seed = (x as f64 * 13.0 + y as f64 * 29.0 + time * 15.0).sin();
let style = if seed > 0.85 {
Style::default()
.fg(ctx.theme.semantic.white)
.add_modifier(Modifier::BOLD)
} else if seed > 0.5 {
ctx.apply(Style::default().fg(base_color))
.add_modifier(Modifier::BOLD)
} else {
ctx.apply(Style::default().fg(base_color))
.add_modifier(Modifier::DIM)
};
ctx.apply(style)
}
fn draw_background_dust(f: &mut Frame, area: Rect, ctx: &ThemeContext) {
let time = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64();
let width = area.width as usize;
let height = area.height as usize;
let mut lines = Vec::with_capacity(height);
let move_angle_x = 0.8;
let move_angle_y = 0.4;
for y in 0..height {
let mut spans = Vec::with_capacity(width);
for x in 0..width {
let speed_3 = 4.0;
let pos_x_3 = x as f64 - (time * speed_3 * move_angle_x);
let pos_y_3 = y as f64 + (time * speed_3 * move_angle_y);
let noise_3 = (pos_x_3 * 0.73 + pos_y_3 * 0.19).sin() * (pos_y_3 * 1.3).cos();
if noise_3 > 0.985 {
spans.push(Span::styled(
"+",
Style::default()
.fg(ctx.state_success())
.add_modifier(Modifier::BOLD),
));
continue;
}
let speed_2 = 4.0;
let pos_x_2 = x as f64 - (time * speed_2 * move_angle_x);
let pos_y_2 = y as f64 + (time * speed_2 * move_angle_y);
let noise_2 = (pos_x_2 * 0.3 + pos_y_2 * 0.8).sin() * (pos_x_2 * 0.4).cos();
if noise_2 > 0.95 {
spans.push(Span::styled(
"ยท",
ctx.apply(Style::default().fg(ctx.state_info())),
));
continue;
}
let speed_1 = 1.5;
let pos_x_1 = x as f64 - (time * speed_1 * move_angle_x);
let pos_y_1 = y as f64 + (time * speed_1 * move_angle_y);
let noise_1 = (pos_x_1 * 0.15 + pos_y_1 * 0.15).sin();
if noise_1 > 0.96 {
spans.push(Span::styled(
".",
Style::default()
.fg(ctx.theme.semantic.surface2)
.add_modifier(Modifier::DIM),
));
continue;
}
spans.push(Span::raw(" "));
}
lines.push(Line::from(spans));
}
let p = Paragraph::new(lines);
f.render_widget(p, area);
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
#[test]
fn welcome_esc_transitions_to_normal() {
let mut app_state = AppState {
mode: AppMode::Welcome,
..Default::default()
};
handle_event(
CrosstermEvent::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
&mut app_state,
);
assert!(matches!(app_state.mode, AppMode::Normal));
}
#[test]
fn welcome_ignores_non_esc_keys() {
let mut app_state = AppState {
mode: AppMode::Welcome,
..Default::default()
};
handle_event(
CrosstermEvent::Key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::NONE)),
&mut app_state,
);
assert!(matches!(app_state.mode, AppMode::Welcome));
}
}