ssh-portfolio/src/components/content.rs
Erica Marigold ee524dc160
feat(blog): implement proper blog pages with full rendering
* 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
2025-08-18 19:10:47 +01:00

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(())
}
}