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.
329 lines
11 KiB
Rust
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(())
|
|
}
|
|
}
|