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, action_tx: mpsc::UnboundedSender, action_rx: mpsc::UnboundedReceiver, ssh_keystroke_rx: mpsc::UnboundedReceiver>, ssh_resize_rx: mpsc::UnboundedReceiver<(u16, u16)>, // TODO: Refactor into its own `Components` struct tabs: Arc>, content: Arc>, cat: Arc>, #[cfg(feature = "blog")] blog_posts: Arc>, } #[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>, resize_rx: mpsc::UnboundedReceiver<(u16, u16)>, ) -> Result { 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>, tui: Arc>>, ) -> 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> = 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(()) } }