forked from DevComp/ssh-portfolio
336 lines
11 KiB
Rust
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(())
|
|
}
|
|
}
|