forked from DevComp/ssh-portfolio
* Render blog headers using a patched `ratatui-image` to export internal `Picker` fields which we use to construct our own picker based on guesses for what capabilities a terminal might have based on `$TERM_PROGRAM` values * Move truncate implementation into `content` module and have other modules import it * Add `terminal` module under `tui` for classifying different terminals and storing information regarding them * Update trait `Component::init` to supply a `TerminalInfo`, to help components adapt themselves to terminal emulator capabilities * Move rust toolchain back to stable, now version 1.87 * Increase rustfmt max width and chain width to 95
372 lines
12 KiB
Rust
372 lines
12 KiB
Rust
use std::sync::atomic::{AtomicUsize, Ordering};
|
|
use std::sync::Arc;
|
|
|
|
use color_eyre::eyre::eyre;
|
|
use color_eyre::Result;
|
|
use figlet_rs::FIGfont;
|
|
use ratatui::prelude::*;
|
|
use ratatui::widgets::*;
|
|
use tokio::sync::mpsc::UnboundedSender;
|
|
|
|
use super::Component;
|
|
use crate::action::Action;
|
|
#[cfg(feature = "blog")]
|
|
use crate::components::Post;
|
|
use crate::config::Config;
|
|
|
|
pub(super) fn truncate(s: &str, max: usize) -> String {
|
|
s.char_indices()
|
|
.find(|(idx, ch)| idx + ch.len_utf8() > max)
|
|
.map_or(s.to_string(), |(idx, _)| s[..idx].to_string() + "...")
|
|
}
|
|
|
|
#[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<Post>> {
|
|
Ok(crate::atproto::blog::get_all_posts()
|
|
.await?
|
|
.iter()
|
|
.map(|post| Arc::new(post.clone()))
|
|
.collect())
|
|
}
|
|
}
|
|
|
|
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(())
|
|
}
|
|
}
|