From fc41a499e6e9fa8416ea4ea31e6c776516bb0324 Mon Sep 17 00:00:00 2001 From: Erica Marigold Date: Sun, 2 Feb 2025 18:28:57 +0000 Subject: [PATCH] feat: impl portfolio design with complete about page 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. --- .config/config.json5 | 4 +- Cargo.lock | 7 + Cargo.toml | 1 + assets/drpepper.flf | 569 +++++++++++++++++++++++++++++ src/action.rs | 4 + src/app.rs | 221 +++++++---- src/components.rs | 13 +- src/components/{home.rs => cat.rs} | 30 +- src/components/content.rs | 329 +++++++++++++++++ src/components/fps.rs | 91 ----- src/components/tabs.rs | 104 ++++++ src/ssh.rs | 99 ++++- src/tui/backend.rs | 90 +++++ src/tui/mod.rs | 9 +- 14 files changed, 1384 insertions(+), 187 deletions(-) create mode 100644 assets/drpepper.flf rename src/components/{home.rs => cat.rs} (60%) create mode 100644 src/components/content.rs delete mode 100644 src/components/fps.rs create mode 100644 src/components/tabs.rs create mode 100644 src/tui/backend.rs diff --git a/.config/config.json5 b/.config/config.json5 index 824f185..5fb9dc3 100644 --- a/.config/config.json5 +++ b/.config/config.json5 @@ -5,7 +5,9 @@ "": "Quit", // Another way to quit "": "Quit", // Yet another way to quit "": "Quit", // Final way to quit - "": "Suspend" // Suspend the application + "": "Suspend", // Suspend the application + "": "NextTab", // Go to the next tab + "": "PrevTab", // Go to the previous tab }, } } diff --git a/Cargo.lock b/Cargo.lock index 68c335c..22f753e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1017,6 +1017,12 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "figlet-rs" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4742a071cd9694fc86f9fa1a08fa3e53d40cc899d7ee532295da2d085639fbc5" + [[package]] name = "filetime" version = "0.2.25" @@ -3432,6 +3438,7 @@ dependencies = [ "crossterm", "derive_deref", "directories", + "figlet-rs", "futures", "human-panic", "indoc", diff --git a/Cargo.toml b/Cargo.toml index e377ac5..a3ef481 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ config = "0.14.0" crossterm = { version = "0.28.1", features = ["serde", "event-stream"] } derive_deref = "1.1.1" directories = "5.0.1" +figlet-rs = "0.1.5" futures = "0.3.31" human-panic = "2.0.2" indoc = "2.0.5" diff --git a/assets/drpepper.flf b/assets/drpepper.flf new file mode 100644 index 0000000..901160b --- /dev/null +++ b/assets/drpepper.flf @@ -0,0 +1,569 @@ +flf2a$ 5 4 20 0 16 +Font : Dr. Pepper (after a name in one sig done in this style). +Author: Eero Tamminen, t150315@cc.tut.fi. + +Characters '#' and '&' are lousy and I'm not very satisfied +with the '$' or 't'... Suggestions? + +Explanation of first line: +flf2 - "magic number" for file identifiction +a - should always be `a', for now +$ - the "hardblank" -- prints s a blank, but can't be smushed +5 - height of a character +4 - height of a character, not including descenders +20 - max line length (excluding comment lines) + fudge factor +0 - default smushmode for this font +16 - number of comment lines + +$@ +$@ +$@ +$@ +$@@ + _ @ +| |@ +|_/@ +<_>@ + @@ + _ _@ +|/|/@ + @ + @ + @@ + @ +$_|_|_$@ +$_|_|_$@ + | | @ + @@ + @ + ||_@ +<_-<@ +/__/@ + || @@ + __@ +<>/ /@ + / / @ +/_/<>@ + @@ + _ @ +< > @ +/.\/$@ +\_/\$@ + @@ + _@ +|/@ + @ + @ + @@ + __@ + / /@ +| | @ +| | @ + \_\@@ +__ @ +\ \ @ + | |@ + | |@ +/_/ @@ + @ +_/\_@ +> <@ + \/ @ + @@ + _ @ + _| |_ @ +|_ _|@ + |_| @ + @@ + @ + @ + _@ +|/@ + @@ + @ + ___ @ +|___|@ + @ + @@ + @ + @ + _ @ +<_>@ + @@ + __@ + / /@ + / / @ +/_/ @ + @@ + ___ @ +| |@ +| / |@ +`___'@ + @@ + _ @ +/ |@ +| |@ +|_|@ + @@ + ___ @ +<_ >@ + / / @ +<___>@ + @@ + ____@ +<__ /@ + <_ \@ +<___/@ + @@ + __ @ + /. | @ +/_ .|@ + |_| @ + @@ + ___ @ +| __|@ +`__ \@ +|___/@ + @@ + ___ @ +| __>@ +| . \@ +`___/@ + @@ + ___ @ +|_ |@ + / / @ +/_/ @ + @@ + ___ @ +< . >@ +/ . \@ +\___/@ + @@ + ___ @ +| . |@ +`_ /@ + /_/ @ + @@ + _ @ +<_>@ + _ @ +<_>@ + @@ + _ @ +<_>@ + _ @ +|/ @ + @@ + __@ + / /@ +< < @ + \_\@ + @@ + ___ @ +|___|@ + ___ @ +|___|@ + @@ +__ @ +\ \ @ + > >@ +/_/ @ + @@ + ___ @ +<_. >@ + /_/ @ + <_> @ + @@ + ___ @ +| "|@ +| \_|@ +`___/@ + @@ + ___ @ +| . |@ +| |@ +|_|_|@ + @@ + ___ @ +| . >@ +| . \@ +|___/@ + @@ + ___ @ +| _>@ +| <__@ +`___/@ + @@ + ___ @ +| . \@ +| | |@ +|___/@ + @@ + ___ @ +| __>@ +| _> @ +|___>@ + @@ + ___ @ +| __>@ +| _> @ +|_| @ + @@ + ___ @ +/ _> @ +| <_/\@ +`____/@ + @@ + _ _ @ +| | |@ +| |@ +|_|_|@ + @@ + _ @ +| |@ +| |@ +|_|@ + @@ + _ @ + | |@ +_| |@ +\__/@ + @@ + _ __@ +| / /@ +| \ @ +|_\_\@ + @@ + _ @ +| | @ +| |_ @ +|___|@ + @@ + __ __ @ +| \ \@ +| |@ +|_|_|_|@ + @@ + _ _ @ +| \ |@ +| |@ +|_\_|@ + @@ + ___ @ +| . |@ +| | |@ +`___'@ + @@ + ___ @ +| . \@ +| _/@ +|_| @ + @@ + ___ @ +| . |@ +| | |@ +`___\@ + @@ + ___ @ +| . \@ +| /@ +|_\_\@ + @@ + ___ @ +/ __>@ +\__ \@ +<___/@ + @@ + ___ @ +|_ _|@ + | | @ + |_| @ + @@ + _ _ @ +| | |@ +| ' |@ +`___'@ + @@ + _ _ @ +| | |@ +| ' |@ +|__/ @ + @@ + _ _ _ @ +| | | |@ +| | | |@ +|__/_/ @ + @@ +__ _$@ +\ \/ @ + \ \ @ +_/\_\ @ + @@ + _ _ @ +| | |@ +\ /@ + |_| @ + @@ + ____@ +|_ /@ + / / @ +/___|@ + @@ + ___ @ +| _|@ +| | @ +| |_ @ +|___|@@ +__ @ +\ \ @ + \ \ @ + \_\@ + @@ + ___ @ +|_ |@ + | |@ + _| |@ +|___|@@ + /\ @ +@ + @ + @ + @@ + @ + @ + ___ @ +|___|@ + @@ +_ @ +\|@ + @ + @ + @@ + @ + ___ @ +<_> |@ +<___|@ + @@ + _ @ +| |_ @ +| . \@ +|___/@ + @@ + @ + ___ @ +/ | '@ +\_|_.@ + @@ + _ @ + _| |@ +/ . |@ +\___|@ + @@ + @ + ___ @ +/ ._>@ +\___.@ + @@ + ___ @ +| | '@ +| |- @ +|_| @ + @@ + @ + ___ @ +/ . |@ +\_. |@ +<___'@@ + _ @ +| |_ @ +| . |@ +|_|_|@ + @@ + _ @ +<_>@ +| |@ +|_|@ + @@ + _ @ + <_>@ + | |@ + | |@ +<__'@@ + _ @ +| |__@ +| / /@ +|_\_\@ + @@ + _ @ +| |@ +| |@ +|_|@ + @@ + @ +._ _ _ @ +| ' ' |@ +|_|_|_|@ + @@ + @ +._ _ @ +| ' |@ +|_|_|@ + @@ + @ + ___ @ +/ . \@ +\___/@ + @@ + @ + ___ @ +| . \@ +| _/@ +|_| @@ + @ + ___ @ +/ . |@ +\_ |@ + |_|@@ + @ + _ _ @ +| '_>@ +|_| @ + @@ + @ + ___@ +<_-<@ +/__/@ + @@ + _ @ +$_| |_$@ + | | @ + |_| @ + @@ + @ + _ _ @ +| | |@ +`___|@ + @@ + @ + _ _ @ +| | |@ +|__/ @ + @@ + @ + _ _ _ @ +| | | |@ +|__/_/ @ + @@ + @ +__ @ +\ \/@ +/\_\@ + @@ + @ + _ _ @ +| | |@ +`_. |@ +<___'@@ + @ +.___@ + / /@ +/___@ + @@ + __@ + / /@ +/ | @ +\ | @ + \_\@@ +||@ +||@ +||@ +||@ + @@ +__ @ +\ \ @ + | \@ + | /@ +/_/ @@ + @ + /\/|@ +|/\/ @ + @ + @@ +<>_<>@ +| . |@ +| |@ +|_|_|@ + @@ +<>_<>@ +| . |@ +| | |@ +`___'@ + @@ +<>_<>@ +| | |@ +| ' |@ +`___'@ + @@ + @ +<>_<>@ +`_> |@ +<___|@ + @@ + @ +<>_<>@ +/ . \@ +\___/@ + @@ + @ +<>_<>@ +| | |@ +`___|@ + @@ + ___ @ +| . >@ +| . \@ +| ._/@ +|_| @@ +196 +<>_<>@ +| . |@ +| |@ +|_|_|@ + @@ +214 +<>_<>@ +| . |@ +| | |@ +`___'@ + @@ +220 +<>_<>@ +| | |@ +| ' |@ +`___'@ + @@ +223 + ___ @ +| . >@ +| . \@ +| ._/@ +|_| @@ +228 + @ +<>_<>@ +`_> |@ +<___|@ + @@ +246 + @ +<>_<>@ +/ . \@ +\___/@ + @@ +252 + @ +<>_<>@ +| | |@ +`___|@ + @@ diff --git a/src/action.rs b/src/action.rs index 2830433..0340a7b 100644 --- a/src/action.rs +++ b/src/action.rs @@ -12,4 +12,8 @@ pub enum Action { ClearScreen, Error(String), Help, + + // Tab management + NextTab, + PrevTab } diff --git a/src/app.rs b/src/app.rs index 5ce7670..32749c6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,8 +1,14 @@ -use std::sync::Arc; +use std::sync::{atomic::AtomicUsize, Arc}; use color_eyre::{eyre, Result}; use crossterm::event::{KeyCode, KeyEvent}; -use ratatui::prelude::Rect; +use ratatui::{ + layout::{Constraint, Direction, Layout}, + prelude::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::Paragraph, +}; use serde::{Deserialize, Serialize}; use tokio::{ sync::{mpsc, Mutex, RwLock}, @@ -13,7 +19,7 @@ use tracing::{debug, info}; use crate::{ action::Action, - components::{fps::FpsCounter, home::Home, Component}, + components::*, config::Config, keycode::KeyCodeExt, tui::{Event, Terminal, Tui}, @@ -23,14 +29,20 @@ pub struct App { config: Config, tick_rate: f64, frame_rate: f64, - components: Vec>>>, should_quit: bool, should_suspend: bool, mode: Mode, last_tick_key_events: Vec, action_tx: mpsc::UnboundedSender, action_rx: mpsc::UnboundedReceiver, - ssh_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>, } #[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] @@ -43,16 +55,24 @@ impl App { pub fn new( tick_rate: f64, frame_rate: f64, - ssh_rx: mpsc::UnboundedReceiver>, + 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())); + Ok(Self { tick_rate, frame_rate, - components: vec![ - Box::new(Arc::new(Mutex::new(Home::new()))), - Box::new(Arc::new(Mutex::new(FpsCounter::default()))), - ], should_quit: false, should_suspend: false, config: Config::new()?, @@ -60,7 +80,13 @@ impl App { last_tick_key_events: Vec::new(), action_tx, action_rx, - ssh_rx, + + ssh_keystroke_rx: keystroke_rx, + ssh_resize_rx: resize_rx, + + tabs, + content, + cat, }) } @@ -72,7 +98,6 @@ impl App { let mut tui = tui.write().await; let mut tui = tui.get_or_insert( Tui::new(term)? - // .mouse(true) // uncomment this line to enable mouse support .tick_rate(self.tick_rate) .frame_rate(self.frame_rate), ); @@ -81,23 +106,33 @@ impl App { block_in_place(|| { tui.enter()?; - for component in self.components.iter_mut() { - component - .try_lock()? - .register_action_handler(self.action_tx.clone())?; - } + // 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())?; - for component in self.components.iter_mut() { - component - .try_lock()? - .register_config_handler(self.config.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())?; - for component in self.components.iter_mut() { - component - .try_lock()? - .init(tui.terminal.try_lock()?.size()?)?; - } + // Initialize components + let size = tui.terminal.try_lock()?.size()?; + self.tabs.try_lock()?.init(size)?; + self.content.try_lock()?.init(size)?; + self.cat.try_lock()?.init(size)?; Ok::<_, eyre::Error>(()) })?; @@ -106,19 +141,17 @@ impl App { let mut resume_tx: Option> = None; loop { self.handle_events(&mut tui).await?; - // self.handle_actions(&mut tui)?; block_in_place(|| self.handle_actions(&mut tui))?; if self.should_suspend { if let Some(ref tx) = resume_tx { - tx.cancel(); + tx.cancel(); resume_tx = None; } else { resume_tx = Some(tui.suspend().await?); - continue + continue; } action_tx.send(Action::Resume)?; action_tx.send(Action::ClearScreen)?; - // tui.mouse(true); block_in_place(|| tui.enter())?; } else if self.should_quit { block_in_place(|| tui.stop())?; @@ -132,7 +165,6 @@ impl App { async fn handle_events(&mut self, tui: &mut Tui) -> Result<()> { tokio::select! { Some(event) = tui.next_event() => { - // Wait for next event and fire required actions for components let action_tx = self.action_tx.clone(); match event { Event::Quit => action_tx.send(Action::Quit)?, @@ -143,21 +175,30 @@ impl App { _ => {} }; - for component in self.components.iter_mut() { - let mut component = component.try_lock()?; - if let Some(action) = block_in_place(|| component.handle_events(Some(event.clone())))? { - action_tx.send(action)?; - } + // 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(ssh_data) = self.ssh_rx.recv() => { - // Receive keystroke data from SSH connection - let key_event = KeyCode::from_xterm_seq(&ssh_data[..]).into_key_event(); + + 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 { @@ -169,11 +210,7 @@ impl App { action_tx.send(action.clone())?; } _ => { - // If the key was not handled as a single key action, - // then consider it for multi-key combinations self.last_tick_key_events.push(key); - - // Check for multi-key combinations if let Some(action) = keymap.get(&self.last_tick_key_events) { info!("Got action: {action:?}"); action_tx.send(action.clone())?; @@ -196,35 +233,97 @@ impl App { Action::Suspend => self.should_suspend = true, Action::Resume => self.should_suspend = false, Action::ClearScreen => tui.terminal.try_lock()?.clear()?, - Action::Resize(w, h) => self.handle_resize(tui, w, h)?, + Action::Resize(w, h) => self.resize(tui, w, h)?, Action::Render => self.render(tui)?, _ => {} } - for component in self.components.iter_mut() { - if let Some(action) = component.try_lock()?.update(action.clone())? { - self.action_tx.send(action)? - }; + // 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)?; } } Ok(()) } - fn handle_resize(&mut self, tui: &mut Tui, w: u16, h: u16) -> Result<()> { - tui.terminal.try_lock()?.resize(Rect::new(0, 0, w, h))?; - self.render(tui)?; - 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<()> { - tui.terminal.try_lock()?.draw(|frame| { - for component in self.components.iter_mut() { - if let Err(err) = component.blocking_lock().draw(frame, frame.area()) { - let _ = self - .action_tx - .send(Action::Error(format!("Failed to draw: {:?}", err))); - } - } + tui.terminal.try_lock()?.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 + self.tabs + .try_lock() + .map_err(|err| std::io::Error::other(err))? + .draw( + frame, + Rect { + x: chunks[0].x + 14, + y: chunks[0].y + 1, + width: chunks[0].width - 6, + height: chunks[0].height, + }, + ) + .map_err(|err| std::io::Error::other(err))?; + + // Render the content + self.content + .try_lock() + .map_err(|err| std::io::Error::other(err))? + .draw( + frame, + Rect { + x: chunks[1].x, + y: chunks[1].y, + width: chunks[0].width, + height: frame.area().height - chunks[0].height, + }, + ) + .map_err(|err| std::io::Error::other(err))?; + + // Render the eepy cat :3 + self.cat + .try_lock() + .map_err(|err| std::io::Error::other(err))? + .draw(frame, frame.area()) + .map_err(|err| std::io::Error::other(err))?; + + Ok::<_, std::io::Error>(()) })?; Ok(()) } diff --git a/src/components.rs b/src/components.rs index e500658..60027d6 100644 --- a/src/components.rs +++ b/src/components.rs @@ -8,8 +8,17 @@ use tokio::sync::mpsc::UnboundedSender; use crate::{action::Action, config::Config, tui::Event}; -pub mod fps; -pub mod home; +// +// Component re-exports +// + +mod tabs; +mod content; +mod cat; + +pub use tabs::*; +pub use content::*; +pub use cat::*; /// `Component` is a trait that represents a visual and interactive element of the user interface. /// diff --git a/src/components/home.rs b/src/components/cat.rs similarity index 60% rename from src/components/home.rs rename to src/components/cat.rs index f6033da..762fa84 100644 --- a/src/components/home.rs +++ b/src/components/cat.rs @@ -6,18 +6,18 @@ use super::Component; use crate::{action::Action, config::Config}; #[derive(Default)] -pub struct Home { +pub struct Cat { command_tx: Option>, config: Config, } -impl Home { +impl Cat { pub fn new() -> Self { Self::default() } } -impl Component for Home { +impl Component for Cat { fn register_action_handler(&mut self, tx: UnboundedSender) -> Result<()> { self.command_tx = Some(tx); Ok(()) @@ -30,19 +30,29 @@ impl Component for Home { fn update(&mut self, action: Action) -> Result> { match action { - Action::Tick => { - // add any logic here that should run on every tick - } - Action::Render => { - // add any logic here that should run on every render - } + Action::Tick => {} + Action::Render => {} _ => {} } Ok(None) } fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { - frame.render_widget(Paragraph::new("hello world"), area); + const CART: &str = r#" |\__/,| (`\ + |_ _ |.--.) ) + ( T ) / + (((^_(((/(((_>"#; + + frame.render_widget( + Paragraph::new(CART).style(Style::default().fg(Color::Magenta).add_modifier(Modifier::SLOW_BLINK | Modifier::BOLD)), + Rect { + x: area.width - 17, + y: area.height - 4, + width: 16, + height: 6, + }, + ); + Ok(()) } } diff --git a/src/components/content.rs b/src/components/content.rs new file mode 100644 index 0000000..c51d8a4 --- /dev/null +++ b/src/components/content.rs @@ -0,0 +1,329 @@ +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>, + config: Config, + selected_tab: Arc, +} + +impl Content { + pub fn new(selected_tab: Arc) -> Self { + Self { + selected_tab, + ..Default::default() + } + } + + /// Generate the content for the "About" tab + fn about_content(&self, area: Rect) -> Result>> { + 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 = 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::>>(); + + 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> { + vec![Line::from("WIP")] + } + + /// Generate the content for the "Blog" tab + fn blog_content(&self) -> Vec> { + vec![Line::from("coming soon! :^)")] + } +} + +impl Component for Content { + fn register_action_handler(&mut self, tx: UnboundedSender) -> 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> { + 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(()) + } +} diff --git a/src/components/fps.rs b/src/components/fps.rs deleted file mode 100644 index a79c4b4..0000000 --- a/src/components/fps.rs +++ /dev/null @@ -1,91 +0,0 @@ -use std::time::Instant; - -use color_eyre::Result; -use ratatui::{ - layout::{Constraint, Layout, Rect}, - style::{Style, Stylize}, - text::Span, - widgets::Paragraph, - Frame, -}; - -use super::Component; - -use crate::action::Action; - -#[derive(Debug, Clone, PartialEq)] -pub struct FpsCounter { - last_tick_update: Instant, - tick_count: u32, - ticks_per_second: f64, - - last_frame_update: Instant, - frame_count: u32, - frames_per_second: f64, -} - -impl Default for FpsCounter { - fn default() -> Self { - Self::new() - } -} - -impl FpsCounter { - pub fn new() -> Self { - Self { - last_tick_update: Instant::now(), - tick_count: 0, - ticks_per_second: 0.0, - last_frame_update: Instant::now(), - frame_count: 0, - frames_per_second: 0.0, - } - } - - fn app_tick(&mut self) -> Result<()> { - self.tick_count += 1; - let now = Instant::now(); - let elapsed = (now - self.last_tick_update).as_secs_f64(); - if elapsed >= 1.0 { - self.ticks_per_second = self.tick_count as f64 / elapsed; - self.last_tick_update = now; - self.tick_count = 0; - } - Ok(()) - } - - fn render_tick(&mut self) -> Result<()> { - self.frame_count += 1; - let now = Instant::now(); - let elapsed = (now - self.last_frame_update).as_secs_f64(); - if elapsed >= 1.0 { - self.frames_per_second = self.frame_count as f64 / elapsed; - self.last_frame_update = now; - self.frame_count = 0; - } - Ok(()) - } -} - -impl Component for FpsCounter { - fn update(&mut self, action: Action) -> Result> { - match action { - Action::Tick => self.app_tick()?, - Action::Render => self.render_tick()?, - _ => {} - }; - Ok(None) - } - - fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { - let [top, _] = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).areas(area); - let message = format!( - "{:.2} ticks/sec, {:.2} FPS", - self.ticks_per_second, self.frames_per_second - ); - let span = Span::styled(message, Style::new().dim()); - let paragraph = Paragraph::new(span).right_aligned(); - frame.render_widget(paragraph, top); - Ok(()) - } -} diff --git a/src/components/tabs.rs b/src/components/tabs.rs new file mode 100644 index 0000000..2adeea5 --- /dev/null +++ b/src/components/tabs.rs @@ -0,0 +1,104 @@ +use std::sync::{atomic::{AtomicUsize, Ordering}, Arc}; + +use color_eyre::Result; +use ratatui::{prelude::*, widgets::*}; +use tokio::sync::mpsc::UnboundedSender; + +use super::Component; +use crate::{action::Action, config::Config}; + +#[derive(Default)] +pub struct Tabs { + command_tx: Option>, + config: Config, + tabs: Vec<&'static str>, + selected_tab: Arc, +} + +impl Tabs { + pub fn new(tabs: Vec<&'static str>, selected_tab: Arc) -> Self { + Self { + tabs, + selected_tab, + ..Default::default() + } + } + + pub fn next(&mut self) { + if self.selected_tab.load(Ordering::Relaxed) < self.tabs.len() - 1 { + self.selected_tab.fetch_add(1, Ordering::Relaxed); + } + } + + pub fn previous(&mut self) { + if self.selected_tab.load(Ordering::Relaxed) > 0 { + self.selected_tab.fetch_sub(1, Ordering::Relaxed); + } + } +} + +impl Component for Tabs { + fn register_action_handler(&mut self, tx: UnboundedSender) -> 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> { + match action { + Action::Tick => {} + Action::Render => {} + Action::NextTab => self.next(), + Action::PrevTab => self.previous(), + _ => {} + }; + + Ok(None) + } + + fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { + let mut tab_lines = vec![Line::default(), Line::default()]; + + for (i, &tab) in self.tabs.iter().enumerate() { + let style = if self.selected_tab.load(Ordering::Relaxed) == i { + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + + tab_lines[0].spans.push(Span::styled( + format!("╭{}╮", "─".repeat(tab.len() + 2)), + Style::default().fg(Color::DarkGray), + )); + + tab_lines[1] + .spans + .push(Span::styled("│", Style::default().fg(Color::DarkGray))); + tab_lines[1] + .spans + .push(Span::styled(format!(" {} ", tab), style)); + tab_lines[1] + .spans + .push(Span::styled("│", Style::default().fg(Color::DarkGray))); + } + + let tabs_widget = Paragraph::new(tab_lines).block(Block::default().borders(Borders::NONE)); + + frame.render_widget( + tabs_widget, + Rect { + x: area.x, + y: area.y, + width: area.width, + height: 2, + }, + ); + + Ok(()) + }} diff --git a/src/ssh.rs b/src/ssh.rs index 9fa78e4..21b6a0c 100644 --- a/src/ssh.rs +++ b/src/ssh.rs @@ -2,20 +2,19 @@ use std::{io::Write, net::SocketAddr, sync::Arc}; use async_trait::async_trait; use color_eyre::eyre::{self, eyre}; -use ratatui::prelude::CrosstermBackend; use russh::{ server::{Auth, Handle, Handler, Msg, Server, Session}, Channel, ChannelId, CryptoVec, Pty, }; use tokio::{ runtime::Handle as TokioHandle, - sync::{mpsc, Mutex, RwLock}, + sync::{mpsc, oneshot, Mutex, RwLock}, }; use tracing::instrument; use crate::{ app::App, - tui::{Terminal, Tui}, + tui::{backend::SshBackend, Terminal, Tui}, OPTIONS, }; @@ -29,10 +28,10 @@ pub struct TermWriter { impl TermWriter { #[instrument(skip(session, channel), level = "trace", fields(channel_id = %channel.id()))] - fn new(session: &mut Session, channel: Channel) -> Self { + fn new(session: Handle, channel: Channel) -> Self { tracing::trace!("Acquiring new SSH writer"); Self { - session: session.handle(), + session, channel, inner: CryptoVec::new(), } @@ -72,20 +71,33 @@ impl Write for TermWriter { pub struct SshSession { app: Option>>, - ssh_tx: mpsc::UnboundedSender>, + keystroke_tx: mpsc::UnboundedSender>, + resize_tx: mpsc::UnboundedSender<(u16, u16)>, + init_dims_tx: Option>, + init_dims_rx: Option>, tui: Arc>>, } impl SshSession { pub fn new() -> Self { - let (ssh_tx, ssh_rx) = mpsc::unbounded_channel(); + let (keystroke_tx, keystroke_rx) = mpsc::unbounded_channel(); + let (resize_tx, resize_rx) = mpsc::unbounded_channel(); + let (init_dims_tx, init_dims_rx) = oneshot::channel(); Self { - app: App::new(OPTIONS.tick_rate, OPTIONS.frame_rate, ssh_rx) - .ok() - .map(|app| Arc::new(Mutex::new(app))), + app: App::new( + OPTIONS.tick_rate, + OPTIONS.frame_rate, + keystroke_rx, + resize_rx, + ) + .ok() + .map(|app| Arc::new(Mutex::new(app))), tui: Arc::new(RwLock::new(None)), - ssh_tx, + keystroke_tx, + resize_tx, + init_dims_tx: Some(init_dims_tx), + init_dims_rx: Some(init_dims_rx), // Only an option so that I can take ownership of it } } @@ -96,6 +108,12 @@ impl SshSession { session: &Handle, channel_id: ChannelId, ) -> eyre::Result<()> { + // let mut tui_inner = Tui::new(writer.clone())?; + // let mut app_tmp = app.lock().await; + // app_tmp.resize(&mut tui_inner, 169, 34)?; + // drop(app_tmp); + // tui.write().await.get_or_insert(tui_inner); + app.lock_owned().await.run(writer, tui).await?; session .close(channel_id) @@ -128,17 +146,31 @@ impl Handler for SshSession { let channel_id = channel.id(); let inner_app = Arc::clone(app); - let term = Terminal::new(CrosstermBackend::new(TermWriter::new(session, channel)))?; - let writer = Arc::new(Mutex::new(term)); let tui = Arc::clone(&self.tui); + let rx = self.init_dims_rx.take().unwrap(); tracing::info!("Serving app to open session"); tokio::task::spawn(async move { - let res = Self::run_app(inner_app, writer, tui, &session_handle, channel_id).await; - match res { - Ok(()) => tracing::info!("session exited"), + let result: Result<(), Box> = (|| async { + let ((term_width, term_height), (pixel_width, pixel_height)) = rx.await?; + let writer = Arc::new(Mutex::new(Terminal::new(SshBackend::new( + TermWriter::new(session_handle.clone(), channel), + term_width, + term_height, + pixel_width, + pixel_height, + ))?)); + + Self::run_app(inner_app, writer, tui, &session_handle, channel_id).await?; + Ok(()) + })( + ) + .await; + + match result { + Ok(()) => tracing::info!("Session exited successfully"), Err(err) => { - tracing::error!("Session exited with error: {err}"); + tracing::error!("Session errored: {err}"); let _ = session_handle.channel_failure(channel_id).await; } } @@ -149,6 +181,7 @@ impl Handler for SshSession { Err(eyre!("Failed to initialize App for session")) } + #[instrument(skip_all, fields(channel_id = %channel_id))] async fn pty_request( &mut self, @@ -169,6 +202,18 @@ impl Handler for SshSession { return Err(eyre!("Unsupported terminal type: {term}")); } + let tx = self.init_dims_tx.take().unwrap(); + if !tx.is_closed() { + // If we've not already initialized the terminal, send the initial dimensions + tracing::debug!("Sending initial pty dimensions"); + tx.send(( + (col_width as u16, row_height as u16), + (pix_width as u16, pix_height as u16), + )) + .map_err(|_| eyre!("Failed to send initial pty dimensions"))?; + } + + session.channel_success(channel_id)?; Ok(()) } @@ -180,10 +225,28 @@ impl Handler for SshSession { _session: &mut Session, ) -> Result<(), Self::Error> { tracing::debug!("Received keystroke data from SSH: {:?}, sending", data); - self.ssh_tx + self.keystroke_tx .send(data.to_vec()) .map_err(|_| eyre!("Failed to send event keystroke data")) } + + async fn window_change_request( + &mut self, + _: ChannelId, + col_width: u32, + row_height: u32, + _: u32, + _: u32, + _: &mut Session, + ) -> Result<(), Self::Error> { + // TODO: actually make it resize properly + // That would involve first updating the Backend's size and then updating the rect via the event + self.resize_tx + .send((col_width as u16, row_height as u16)) + .map_err(|_| eyre!("Failed to send pty size specifications"))?; + + Ok(()) + } } #[derive(Default)] diff --git a/src/tui/backend.rs b/src/tui/backend.rs new file mode 100644 index 0000000..fd5f72a --- /dev/null +++ b/src/tui/backend.rs @@ -0,0 +1,90 @@ +use std::{io, ops::{Deref, DerefMut}}; + +use ratatui::{ + backend::{Backend, CrosstermBackend, WindowSize}, + layout::Size, +}; + +use crate::ssh::TermWriter; + +#[derive(Debug)] +pub struct SshBackend { + inner: CrosstermBackend, + pub dims: (u16, u16), + pub pixel: (u16, u16), +} + +impl SshBackend { + pub fn new(writer: TermWriter, init_width: u16, init_height: u16, init_pixel_width: u16, init_pixdel_height: u16) -> Self { + let inner = CrosstermBackend::new(writer); + SshBackend { + inner, + dims: (init_width, init_height), + pixel: (init_pixel_width, init_pixdel_height), + } + } +} + +impl Backend for SshBackend { + fn size(&self) -> io::Result { + Ok(Size { + width: self.dims.0, + height: self.dims.1, + }) + } + + fn draw<'a, I>(&mut self, content: I) -> io::Result<()> + where + I: Iterator, + { + self.inner.draw(content) + } + + fn hide_cursor(&mut self) -> io::Result<()> { + self.inner.hide_cursor() + } + + fn show_cursor(&mut self) -> io::Result<()> { + self.inner.show_cursor() + } + + fn get_cursor_position(&mut self) -> io::Result { + self.inner.get_cursor_position() + } + + fn set_cursor_position>( + &mut self, + position: P, + ) -> io::Result<()> { + self.inner.set_cursor_position(position) + } + + fn clear(&mut self) -> io::Result<()> { + self.inner.clear() + } + + fn window_size(&mut self) -> io::Result { + Ok(WindowSize { + columns_rows: self.size()?, + pixels: Size { width: self.dims.0, height: self.dims.1 }, + }) + } + + fn flush(&mut self) -> io::Result<()> { + Backend::flush(&mut self.inner) + } +} + +impl Deref for SshBackend { + type Target = CrosstermBackend; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +impl DerefMut for SshBackend { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.inner + } +} \ No newline at end of file diff --git a/src/tui/mod.rs b/src/tui/mod.rs index 2beac59..c8f5d39 100644 --- a/src/tui/mod.rs +++ b/src/tui/mod.rs @@ -2,6 +2,7 @@ use std::{sync::Arc, time::Duration}; +use backend::SshBackend; use color_eyre::Result; use crossterm::{ cursor, @@ -11,7 +12,6 @@ use crossterm::{ }, terminal::{EnterAlternateScreen, LeaveAlternateScreen}, }; -use ratatui::backend::CrosstermBackend as Backend; use serde::{Deserialize, Serialize}; use status::TuiStatus; use tokio::{ @@ -25,9 +25,8 @@ use tokio::{ use tokio_util::sync::CancellationToken; use tracing::error; -use crate::ssh::TermWriter; - pub(crate) mod status; +pub(crate) mod backend; #[derive(Clone, Debug, Serialize, Deserialize)] pub enum Event { @@ -45,7 +44,9 @@ pub enum Event { Resize(u16, u16), } -pub type Terminal = ratatui::Terminal>; +pub type Terminal = ratatui::Terminal; + +#[derive(Debug)] pub struct Tui { pub terminal: Arc>, pub task: JoinHandle<()>,