ssh-portfolio/src/components/content.rs
Erica Marigold fc41a499e6
feat: impl portfolio design with complete about page
Finally implemented the actual portfolio design! This includes a tab
mechanism for various aspects of the portfolio and the complete content
for the about tab.

Also fixes the TUI not being correctly scaled due to crossterm using the
dimensions of the server console tty instead of the client pty by
defining a custom `Backend` for ratatui.
2025-02-02 18:28:57 +00:00

329 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
fn blog_content(&self) -> Vec<Line<'static>> {
vec![Line::from("coming soon! :^)")]
}
}
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 content = match self.selected_tab.load(Ordering::Relaxed) {
0 => self.about_content(area)?,
1 => self.projects_content(),
2 => self.blog_content(),
_ => unreachable!(),
};
// 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(())
}
}