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