forked from DevComp/ssh-portfolio
459 lines
15 KiB
Rust
459 lines
15 KiB
Rust
use std::sync::atomic::AtomicUsize;
|
|
use std::sync::Arc;
|
|
|
|
use color_eyre::{eyre, Result};
|
|
use crossterm::event::{KeyCode, KeyEvent};
|
|
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect};
|
|
use ratatui::style::{Color, Modifier, Style};
|
|
use ratatui::text::{Line, Span};
|
|
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap};
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::sync::{mpsc, Mutex, RwLock};
|
|
use tokio::task::block_in_place;
|
|
use tokio_util::sync::CancellationToken;
|
|
use tracing::debug;
|
|
|
|
use crate::action::Action;
|
|
use crate::components::*;
|
|
use crate::config::Config;
|
|
use crate::keycode::KeyCodeExt;
|
|
use crate::tui::{Event, Terminal, Tui};
|
|
|
|
pub struct App {
|
|
config: Config,
|
|
tick_rate: f64,
|
|
frame_rate: f64,
|
|
|
|
should_quit: bool,
|
|
should_suspend: bool,
|
|
needs_resize: bool,
|
|
|
|
mode: Mode,
|
|
last_tick_key_events: Vec<KeyEvent>,
|
|
action_tx: mpsc::UnboundedSender<Action>,
|
|
action_rx: mpsc::UnboundedReceiver<Action>,
|
|
|
|
ssh_keystroke_rx: mpsc::UnboundedReceiver<Vec<u8>>,
|
|
ssh_resize_rx: mpsc::UnboundedReceiver<(u16, u16)>,
|
|
|
|
// TODO: Refactor into its own `Components` struct
|
|
tabs: Arc<Mutex<Tabs>>,
|
|
content: Arc<Mutex<Content>>,
|
|
cat: Arc<Mutex<Cat>>,
|
|
#[cfg(feature = "blog")]
|
|
blog_posts: Arc<Mutex<BlogPosts>>,
|
|
}
|
|
|
|
#[derive(
|
|
Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize,
|
|
)]
|
|
pub enum Mode {
|
|
#[default]
|
|
Home,
|
|
}
|
|
|
|
impl App {
|
|
pub const MIN_TUI_DIMS: (u16, u16) = (105, 25);
|
|
|
|
pub fn new(
|
|
tick_rate: f64,
|
|
frame_rate: f64,
|
|
keystroke_rx: mpsc::UnboundedReceiver<Vec<u8>>,
|
|
resize_rx: mpsc::UnboundedReceiver<(u16, u16)>,
|
|
) -> Result<Self> {
|
|
let (action_tx, action_rx) = mpsc::unbounded_channel();
|
|
|
|
// Initialize components
|
|
let active_tab = Arc::new(AtomicUsize::new(0));
|
|
let tabs = Arc::new(Mutex::new(Tabs::new(
|
|
vec!["about", "projects", "blog"],
|
|
Arc::clone(&active_tab),
|
|
)));
|
|
let content = Arc::new(Mutex::new(Content::new(active_tab)));
|
|
|
|
let cat = Arc::new(Mutex::new(Cat::new()));
|
|
|
|
#[cfg(feature = "blog")]
|
|
let rt = tokio::runtime::Handle::current();
|
|
#[cfg(feature = "blog")]
|
|
let blog_posts = Arc::new(Mutex::new(BlogPosts::new(
|
|
rt.block_on(content.try_lock()?.blog_content())?,
|
|
)));
|
|
|
|
Ok(Self {
|
|
tick_rate,
|
|
frame_rate,
|
|
should_quit: false,
|
|
should_suspend: false,
|
|
needs_resize: false,
|
|
|
|
config: Config::new()?,
|
|
mode: Mode::Home,
|
|
last_tick_key_events: Vec::new(),
|
|
action_tx,
|
|
action_rx,
|
|
|
|
ssh_keystroke_rx: keystroke_rx,
|
|
ssh_resize_rx: resize_rx,
|
|
|
|
tabs,
|
|
content,
|
|
cat,
|
|
#[cfg(feature = "blog")]
|
|
blog_posts,
|
|
})
|
|
}
|
|
|
|
pub async fn run(
|
|
&mut self,
|
|
term: Arc<Mutex<Terminal>>,
|
|
tui: Arc<RwLock<Option<Tui>>>,
|
|
) -> Result<()> {
|
|
let mut tui = tui.write().await;
|
|
let tui = tui.get_or_insert(
|
|
Tui::new(term)?
|
|
.tick_rate(self.tick_rate)
|
|
.frame_rate(self.frame_rate),
|
|
);
|
|
|
|
// Force the dimensions to be validated before rendering anything by sending a `Resize` event
|
|
let term_size = tui.terminal.try_lock()?.size()?;
|
|
tui.event_tx.send(Event::Resize(term_size.width, term_size.height))?;
|
|
|
|
// Blocking initialization logic for tui and components
|
|
block_in_place(|| {
|
|
tui.enter()?;
|
|
|
|
// Register action handlers
|
|
self.tabs
|
|
.try_lock()?
|
|
.register_action_handler(self.action_tx.clone())?;
|
|
self.content
|
|
.try_lock()?
|
|
.register_action_handler(self.action_tx.clone())?;
|
|
self.cat
|
|
.try_lock()?
|
|
.register_action_handler(self.action_tx.clone())?;
|
|
#[cfg(feature = "blog")]
|
|
self.blog_posts
|
|
.try_lock()?
|
|
.register_action_handler(self.action_tx.clone())?;
|
|
|
|
// Register config handlers
|
|
self.tabs
|
|
.try_lock()?
|
|
.register_config_handler(self.config.clone())?;
|
|
self.content
|
|
.try_lock()?
|
|
.register_config_handler(self.config.clone())?;
|
|
self.cat
|
|
.try_lock()?
|
|
.register_config_handler(self.config.clone())?;
|
|
#[cfg(feature = "blog")]
|
|
self.blog_posts
|
|
.try_lock()?
|
|
.register_config_handler(self.config.clone())?;
|
|
|
|
// Initialize components
|
|
let size = tui.terminal.try_lock()?.size()?;
|
|
self.tabs.try_lock()?.init(size)?;
|
|
self.content.try_lock()?.init(size)?;
|
|
#[cfg(feature = "blog")]
|
|
self.cat.try_lock()?.init(size)?;
|
|
|
|
self.blog_posts.try_lock()?.init(size)?;
|
|
|
|
Ok::<_, eyre::Error>(())
|
|
})?;
|
|
|
|
let action_tx = self.action_tx.clone();
|
|
let mut resume_tx: Option<Arc<CancellationToken>> = None;
|
|
loop {
|
|
self.handle_events(tui).await?;
|
|
block_in_place(|| self.handle_actions(tui))?;
|
|
if self.should_suspend {
|
|
if let Some(ref tx) = resume_tx {
|
|
tx.cancel();
|
|
resume_tx = None;
|
|
} else {
|
|
resume_tx = Some(tui.suspend().await?);
|
|
continue;
|
|
}
|
|
action_tx.send(Action::Resume)?;
|
|
action_tx.send(Action::ClearScreen)?;
|
|
block_in_place(|| tui.enter())?;
|
|
} else if self.should_quit {
|
|
tui.stop().await?;
|
|
break;
|
|
}
|
|
}
|
|
|
|
tui.exit().await
|
|
}
|
|
|
|
async fn handle_events(&mut self, tui: &mut Tui) -> Result<()> {
|
|
tokio::select! {
|
|
Some(event) = tui.next_event() => {
|
|
let action_tx = self.action_tx.clone();
|
|
match event {
|
|
Event::Quit => action_tx.send(Action::Quit)?,
|
|
Event::Tick => action_tx.send(Action::Tick)?,
|
|
Event::Render => action_tx.send(Action::Render)?,
|
|
Event::Resize(x, y) => action_tx.send(Action::Resize(x, y))?,
|
|
Event::Key(key) => block_in_place(|| self.handle_key_event(key))?,
|
|
_ => {}
|
|
};
|
|
|
|
// Handle events for each component
|
|
if let Some(action) = self.tabs.try_lock()?.handle_events(Some(event.clone()))? {
|
|
action_tx.send(action)?;
|
|
}
|
|
if let Some(action) = self.content.try_lock()?.handle_events(Some(event.clone()))? {
|
|
action_tx.send(action)?;
|
|
}
|
|
if let Some(action) = self.cat.try_lock()?.handle_events(Some(event.clone()))? {
|
|
action_tx.send(action)?;
|
|
}
|
|
}
|
|
|
|
Some(keystroke_data) = self.ssh_keystroke_rx.recv() => {
|
|
let key_event = KeyCode::from_xterm_seq(&keystroke_data[..]).into_key_event();
|
|
block_in_place(|| self.handle_key_event(key_event))?;
|
|
}
|
|
|
|
Some((width, height)) = self.ssh_resize_rx.recv() => {
|
|
self.action_tx.send(Action::Resize(width, height))?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn handle_key_event(&mut self, key: KeyEvent) -> Result<()> {
|
|
let action_tx = self.action_tx.clone();
|
|
let Some(keymap) = self.config.keybindings.get(&self.mode) else {
|
|
return Ok(());
|
|
};
|
|
match keymap.get(&vec![key]) {
|
|
Some(action) => {
|
|
debug!("Got action: {action:?}");
|
|
action_tx.send(action.clone())?;
|
|
}
|
|
_ => {
|
|
self.last_tick_key_events.push(key);
|
|
if let Some(action) = keymap.get(&self.last_tick_key_events) {
|
|
debug!("Got action: {action:?}");
|
|
action_tx.send(action.clone())?;
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn handle_actions(&mut self, tui: &mut Tui) -> Result<()> {
|
|
while let Ok(action) = self.action_rx.try_recv() {
|
|
if action != Action::Tick && action != Action::Render {
|
|
debug!("{action:?}");
|
|
}
|
|
match action {
|
|
Action::Tick => {
|
|
self.last_tick_key_events.drain(..);
|
|
}
|
|
Action::Quit => {
|
|
if !self.blog_posts.try_lock()?.is_in_post() {
|
|
self.should_quit = true;
|
|
}
|
|
}
|
|
Action::Suspend => self.should_suspend = true,
|
|
Action::Resume => self.should_suspend = false,
|
|
Action::ClearScreen => tui.terminal.try_lock()?.clear()?,
|
|
Action::Resize(w, h) => {
|
|
self.needs_resize =
|
|
w < Self::MIN_TUI_DIMS.0 || h < Self::MIN_TUI_DIMS.1;
|
|
self.resize(tui, w, h)?;
|
|
}
|
|
Action::Render => self.render(tui)?,
|
|
_ => {}
|
|
}
|
|
|
|
// Update each component
|
|
if let Some(action) =
|
|
self.tabs.try_lock()?.update(action.clone())?
|
|
{
|
|
self.action_tx.send(action)?;
|
|
}
|
|
if let Some(action) =
|
|
self.content.try_lock()?.update(action.clone())?
|
|
{
|
|
self.action_tx.send(action)?;
|
|
}
|
|
if let Some(action) = self.cat.try_lock()?.update(action.clone())? {
|
|
self.action_tx.send(action)?;
|
|
}
|
|
|
|
#[cfg(feature = "blog")]
|
|
if let Some(action) =
|
|
self.blog_posts.try_lock()?.update(action.clone())?
|
|
{
|
|
self.action_tx.send(action)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub fn resize(&mut self, tui: &mut Tui, w: u16, h: u16) -> Result<()> {
|
|
let mut term = tui.terminal.try_lock()?;
|
|
term.backend_mut().dims = (w, h);
|
|
term.resize(Rect::new(0, 0, w, h))?;
|
|
drop(term);
|
|
|
|
self.render(tui)
|
|
}
|
|
|
|
fn render(&mut self, tui: &mut Tui) -> Result<()> {
|
|
let mut term = tui.terminal.try_lock()?;
|
|
if self.needs_resize {
|
|
term.draw(|frame| {
|
|
let size = frame.area();
|
|
let error_message = format!(
|
|
"window size must be at least {}x{}, currently {}x{}",
|
|
Self::MIN_TUI_DIMS.0,
|
|
Self::MIN_TUI_DIMS.1,
|
|
size.width,
|
|
size.height
|
|
);
|
|
|
|
let error_width = error_message.chars().count().try_into().unwrap_or(55);
|
|
let error_height = 5;
|
|
|
|
#[rustfmt::skip]
|
|
let area = Block::default()
|
|
.borders(Borders::all())
|
|
.style(Style::new().fg(Color::White))
|
|
.inner(Rect::new(
|
|
size.width
|
|
.checked_sub(error_width)
|
|
.and_then(|n| n.checked_div(2))
|
|
.unwrap_or_default(),
|
|
size.height
|
|
.checked_sub(error_height)
|
|
.and_then(|n| n.checked_div(2))
|
|
.unwrap_or_default(),
|
|
if error_width > size.width { u16::MIN } else { error_width },
|
|
if size.height > error_height { error_height } else { size.height },
|
|
));
|
|
|
|
frame.render_widget(Clear, area);
|
|
frame.render_widget(
|
|
Paragraph::new(
|
|
Line::from(error_message.clone())
|
|
.style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
|
|
)
|
|
.alignment(Alignment::Center)
|
|
.wrap(Wrap { trim: false }),
|
|
area,
|
|
);
|
|
})?;
|
|
|
|
return Ok(());
|
|
}
|
|
|
|
term.try_draw(|frame| {
|
|
let chunks = Layout::default()
|
|
.direction(Direction::Vertical)
|
|
.constraints(
|
|
[Constraint::Length(3), Constraint::Min(0)].as_ref(),
|
|
)
|
|
.split(frame.area());
|
|
|
|
// Render the domain name text
|
|
let title = Paragraph::new(Line::from(Span::styled(
|
|
"devcomp.xyz ",
|
|
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
|
|
)));
|
|
|
|
frame.render_widget(
|
|
title,
|
|
Rect {
|
|
x: chunks[0].x + 2,
|
|
y: chunks[0].y + 2,
|
|
width: 14,
|
|
height: 1,
|
|
},
|
|
);
|
|
|
|
// Render the tabs
|
|
let mut tabs =
|
|
self.tabs.try_lock().map_err(std::io::Error::other)?;
|
|
|
|
tabs.draw(
|
|
frame,
|
|
Rect {
|
|
x: chunks[0].x + 14,
|
|
y: chunks[0].y + 1,
|
|
width: chunks[0].width - 6,
|
|
height: chunks[0].height,
|
|
},
|
|
)
|
|
.map_err(std::io::Error::other)?;
|
|
|
|
// Render the content
|
|
let content_rect = Rect {
|
|
x: chunks[1].x,
|
|
y: chunks[1].y,
|
|
width: chunks[0].width,
|
|
height: frame.area().height - chunks[0].height,
|
|
};
|
|
|
|
self.content
|
|
.try_lock()
|
|
.map_err(std::io::Error::other)?
|
|
.draw(frame, content_rect)
|
|
.map_err(std::io::Error::other)?;
|
|
|
|
// Render the eepy cat :3
|
|
self.cat
|
|
.try_lock()
|
|
.map_err(std::io::Error::other)?
|
|
.draw(frame, frame.area())
|
|
.map_err(std::io::Error::other)?;
|
|
|
|
if tabs.current_tab() == 2 {
|
|
let mut content_rect = content_rect;
|
|
content_rect.x += 1;
|
|
content_rect.y += 1;
|
|
content_rect.width -= 2;
|
|
content_rect.height -= 2;
|
|
|
|
#[cfg(feature = "blog")]
|
|
{
|
|
// Render the post selection list if the blog tab is selected
|
|
self.blog_posts
|
|
.try_lock()
|
|
.map_err(std::io::Error::other)?
|
|
.draw(frame, content_rect)
|
|
.map_err(std::io::Error::other)?;
|
|
}
|
|
|
|
#[cfg(not(feature = "blog"))]
|
|
{
|
|
// If blog feature is not enabled, render a placeholder
|
|
content_rect.height = 1;
|
|
let placeholder = Paragraph::new(
|
|
"Blog feature is disabled. Enable the `blog` feature \
|
|
to view this tab.",
|
|
)
|
|
.style(
|
|
Style::default()
|
|
.fg(Color::Red)
|
|
.add_modifier(Modifier::BOLD),
|
|
);
|
|
|
|
frame.render_widget(placeholder, content_rect);
|
|
}
|
|
}
|
|
|
|
Ok::<_, std::io::Error>(())
|
|
})?;
|
|
Ok(())
|
|
}
|
|
}
|