ssh-portfolio/src/components/content.rs

336 lines
11 KiB
Rust

use std::sync::{
atomic::{AtomicUsize, Ordering},
Arc,
};
use color_eyre::{eyre::eyre, Result};
use figlet_rs::FIGfont;
use ratatui::{prelude::*, widgets::*};
use tokio::sync::mpsc::UnboundedSender;
use super::Component;
use crate::{action::Action, config::Config};
#[derive(Default)]
pub struct Content {
command_tx: Option<UnboundedSender<Action>>,
config: Config,
selected_tab: Arc<AtomicUsize>,
}
impl Content {
pub fn new(selected_tab: Arc<AtomicUsize>) -> Self {
Self {
selected_tab,
..Default::default()
}
}
/// Generate the content for the "About" tab
fn about_content(&self, area: Rect) -> Result<Vec<Line<'static>>> {
let greetings_header = FIGfont::from_content(include_str!("../../assets/drpepper.flf"))
.map_err(|err| eyre!(err))?
.convert("hiya!")
.ok_or(eyre!("Failed to create figlet header for about page"))?
.to_string();
let lines: Vec<String> = greetings_header
.trim_end_matches('\n')
.split('\n')
.map(String::from)
.collect();
let mut content = lines
.iter()
.enumerate()
.map(|(pos, line)| {
if pos == lines.len() - 3 {
return Line::from(vec![
Span::from(" "),
Span::from(line.clone()),
Span::from(" I'm Erica ("),
Span::styled("she/they", Style::default().add_modifier(Modifier::ITALIC)),
Span::from("), and I make scalable systems or something. IDFK."),
]);
}
Line::raw(format!(" {}", line)).style(Style::default().add_modifier(Modifier::BOLD))
})
.collect::<Vec<Line<'static>>>();
content.extend(vec![
Line::from(""),
Line::from(vec![
Span::from(" "),
Span::from("I specialize in systems programming, primarily in "),
Span::styled(
"Rust 🦀",
Style::default()
.fg(Color::LightRed)
.add_modifier(Modifier::BOLD | Modifier::ITALIC),
),
Span::from(" and "),
Span::styled(
"Luau 🦭",
Style::default()
.fg(Color::LightBlue)
.add_modifier(Modifier::BOLD | Modifier::ITALIC),
),
Span::from("."),
]),
Line::from(""),
Line::from(" I am an avid believer of open-source software, and contribute to a few projects such as:"),
]);
let projects = vec![
(Style::default()
.fg(Color::LightMagenta)
.add_modifier(Modifier::BOLD), "lune-org/lune: A standalone Luau runtime"),
(Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD), "DiscordLuau/discord-luau: A Luau library for creating Discord bots, powered by Lune"),
(Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD), "pesde-pkg/pesde: A package manager for the Luau programming language, supporting multiple runtimes including Roblox and Lune"),
];
for (style, project) in projects {
let parts: Vec<&str> = project.splitn(2, ':').collect();
let (left, right) = if parts.len() == 2 {
(parts[0], parts[1])
} else {
(project, "")
};
let formatted_left = Span::styled(left, style);
let bullet = "";
let indent = " ";
let first_line = if project.len() > area.width as usize - bullet.len() {
let split_point = project
.char_indices()
.take_while(|(i, _)| *i < area.width as usize - bullet.len())
.last()
.map(|(i, _)| i)
.unwrap_or(project.len());
let (first, rest) = project.split_at(split_point);
content.push(Line::from(vec![
Span::from(bullet),
formatted_left,
Span::from(":"),
Span::styled(
first
.trim_start_matches(format!("{left}:").as_str())
.to_string(),
Style::default().fg(Color::White),
),
]));
rest.to_string()
} else {
content.push(Line::from(vec![
Span::from(bullet),
formatted_left,
Span::from(":"),
Span::styled(right.to_string(), Style::default().fg(Color::White)),
]));
String::new()
};
let mut remaining_text = first_line;
while !remaining_text.is_empty() {
if remaining_text.len() > area.width as usize - indent.len() {
let split_point = remaining_text
.char_indices()
.take_while(|(i, _)| *i < area.width as usize - indent.len())
.last()
.map(|(i, _)| i)
.unwrap_or(remaining_text.len());
let (first, rest) = remaining_text.split_at(split_point);
content.push(Line::from(vec![
Span::from(indent),
Span::styled(first.to_string(), Style::default().fg(Color::White)),
]));
remaining_text = rest.to_string();
} else {
content.push(Line::from(vec![
Span::from(indent),
Span::styled(remaining_text.clone(), Style::default().fg(Color::White)),
]));
remaining_text.clear();
}
}
}
content.extend(vec![
Line::from(""),
Line::from(
" I am also a fan of the 8 bit aesthetic and think seals are super adorable :3",
),
]);
Ok(content)
}
/// Generate the content for the "Projects" tab
fn projects_content(&self) -> Vec<Line<'static>> {
vec![Line::from("WIP")]
}
/// Generate the content for the "Blog" tab
#[cfg(feature = "blog")]
pub async fn blog_content(&self) -> Result<Vec<String>> {
Ok(crate::atproto::blog::get_all_posts()
.await?
.iter()
.map(|post| post.title.clone().unwrap_or("<unknown>".to_string()))
.collect::<Vec<String>>())
}
}
impl Component for Content {
fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> Result<()> {
self.command_tx = Some(tx);
Ok(())
}
fn register_config_handler(&mut self, config: Config) -> Result<()> {
self.config = config;
Ok(())
}
fn update(&mut self, action: Action) -> Result<Option<Action>> {
match action {
Action::Tick => {}
Action::Render => {}
_ => {}
}
Ok(None)
}
fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
let selected_tab = self.selected_tab.load(Ordering::Relaxed);
let content = match selected_tab {
0 => self.about_content(area)?,
1 => self.projects_content(),
/* Blog tab handled in `App::render` */
_ => vec![],
};
// Create the border lines
let mut border_top = Line::default();
border_top
.spans
.push(Span::styled("", Style::default().fg(Color::DarkGray)));
let devcomp_width = 13;
border_top.spans.push(Span::styled(
"".repeat(devcomp_width),
Style::default().fg(Color::DarkGray),
));
let tabs = ["about", "projects", "blog"];
let mut current_pos = 1 + devcomp_width;
for (i, &tab) in tabs.iter().enumerate() {
let (char, style) = if i == self.selected_tab.load(Ordering::Relaxed) {
(
"",
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
)
} else {
("", Style::default().fg(Color::DarkGray))
};
let default_style = Style::default().fg(Color::DarkGray);
border_top.spans.push(Span::styled("", default_style));
border_top.spans.push(Span::styled("", default_style));
border_top
.spans
.push(Span::styled(char.repeat(tab.len()), style));
border_top.spans.push(Span::styled("", default_style));
border_top.spans.push(Span::styled("", default_style));
current_pos += tab.len() + 4;
}
border_top.spans.push(Span::styled(
"".repeat(area.width as usize - current_pos - 1),
Style::default().fg(Color::DarkGray),
));
border_top
.spans
.push(Span::styled("", Style::default().fg(Color::DarkGray)));
let border_bottom = Line::from(Span::styled(
"".to_owned() + &"".repeat(area.width as usize - 2) + "",
Style::default().fg(Color::DarkGray),
));
let border_left = Span::styled("", Style::default().fg(Color::DarkGray));
let border_right = Span::styled("", Style::default().fg(Color::DarkGray));
// Render the content
let content_widget = Paragraph::new(content)
.block(Block::default().borders(Borders::NONE))
.wrap(Wrap { trim: false });
frame.render_widget(
content_widget,
Rect {
x: area.x + 1,
y: area.y + 1,
width: area.width - 2,
height: area.height - 2,
},
);
// Render the borders
frame.render_widget(
Paragraph::new(border_top),
Rect {
x: area.x,
y: area.y,
width: area.width,
height: 1,
},
);
frame.render_widget(
Paragraph::new(border_bottom),
Rect {
x: area.x,
y: area.y + area.height - 1,
width: area.width,
height: 1,
},
);
for i in 1..area.height - 1 {
frame.render_widget(
Paragraph::new(Line::from(border_left.clone())),
Rect {
x: area.x,
y: area.y + i,
width: 1,
height: 1,
},
);
frame.render_widget(
Paragraph::new(Line::from(border_right.clone())),
Rect {
x: area.x + area.width - 1,
y: area.y + i,
width: 1,
height: 1,
},
);
}
Ok(())
}
}