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.
This commit is contained in:
Erica Marigold 2025-02-02 18:28:57 +00:00
parent 41d47f25b8
commit fc41a499e6
Signed by: DevComp
GPG key ID: 429EF1C337871656
14 changed files with 1384 additions and 187 deletions

View file

@ -5,7 +5,9 @@
"<Ctrl-d>": "Quit", // Another way to quit
"<Ctrl-c>": "Quit", // Yet another way to quit
"<Esc>": "Quit", // Final way to quit
"<Ctrl-z>": "Suspend" // Suspend the application
"<Ctrl-z>": "Suspend", // Suspend the application
"<right>": "NextTab", // Go to the next tab
"<left>": "PrevTab", // Go to the previous tab
},
}
}

7
Cargo.lock generated
View file

@ -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",

View file

@ -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"

569
assets/drpepper.flf Normal file
View file

@ -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
@
<>_<>@
| | |@
`___|@
@@

View file

@ -12,4 +12,8 @@ pub enum Action {
ClearScreen,
Error(String),
Help,
// Tab management
NextTab,
PrevTab
}

View file

@ -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<Box<Arc<Mutex<dyn Component>>>>,
should_quit: bool,
should_suspend: bool,
mode: Mode,
last_tick_key_events: Vec<KeyEvent>,
action_tx: mpsc::UnboundedSender<Action>,
action_rx: mpsc::UnboundedReceiver<Action>,
ssh_rx: mpsc::UnboundedReceiver<Vec<u8>>,
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>>,
}
#[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<Vec<u8>>,
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()));
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
// 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
// Register config handlers
self.tabs
.try_lock()?
.register_config_handler(self.config.clone())?;
}
for component in self.components.iter_mut() {
component
self.content
.try_lock()?
.init(tui.terminal.try_lock()?.size()?)?;
}
.register_config_handler(self.config.clone())?;
self.cat
.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)?;
self.cat.try_lock()?.init(size)?;
Ok::<_, eyre::Error>(())
})?;
@ -106,7 +141,6 @@ impl App {
let mut resume_tx: Option<Arc<CancellationToken>> = 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 {
@ -114,11 +148,10 @@ impl App {
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())))? {
// 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(())
}

View file

@ -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.
///

View file

@ -6,18 +6,18 @@ use super::Component;
use crate::{action::Action, config::Config};
#[derive(Default)]
pub struct Home {
pub struct Cat {
command_tx: Option<UnboundedSender<Action>>,
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<Action>) -> Result<()> {
self.command_tx = Some(tx);
Ok(())
@ -30,19 +30,29 @@ impl Component for Home {
fn update(&mut self, action: Action) -> Result<Option<Action>> {
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(())
}
}

329
src/components/content.rs Normal file
View file

@ -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<UnboundedSender<Action>>,
config: Config,
selected_tab: Arc<AtomicUsize>,
}
impl Content {
pub fn new(selected_tab: Arc<AtomicUsize>) -> Self {
Self {
selected_tab,
..Default::default()
}
}
/// Generate the content for the "About" tab
fn about_content(&self, area: Rect) -> Result<Vec<Line<'static>>> {
let greetings_header = FIGfont::from_content(include_str!("../../assets/drpepper.flf"))
.map_err(|err| eyre!(err))?
.convert("hiya!")
.ok_or(eyre!("Failed to create figlet header for about page"))?
.to_string();
let lines: Vec<String> = greetings_header
.trim_end_matches('\n')
.split('\n')
.map(String::from)
.collect();
let mut content = lines
.iter()
.enumerate()
.map(|(pos, line)| {
if pos == lines.len() - 3 {
return Line::from(vec![
Span::from(" "),
Span::from(line.clone()),
Span::from(" I'm Erica ("),
Span::styled("she/they", Style::default().add_modifier(Modifier::ITALIC)),
Span::from("), and I make scalable systems or something. IDFK."),
]);
}
Line::raw(format!(" {}", line)).style(Style::default().add_modifier(Modifier::BOLD))
})
.collect::<Vec<Line<'static>>>();
content.extend(vec![
Line::from(""),
Line::from(vec![
Span::from(" "),
Span::from("I specialize in systems programming, primarily in "),
Span::styled(
"Rust 🦀",
Style::default()
.fg(Color::LightRed)
.add_modifier(Modifier::BOLD | Modifier::ITALIC),
),
Span::from(" and "),
Span::styled(
"Luau 🦭",
Style::default()
.fg(Color::LightBlue)
.add_modifier(Modifier::BOLD | Modifier::ITALIC),
),
Span::from("."),
]),
Line::from(""),
Line::from(" I am an avid believer of open-source software, and contribute to a few projects such as:"),
]);
let projects = vec![
(Style::default()
.fg(Color::LightMagenta)
.add_modifier(Modifier::BOLD), "lune-org/lune: A standalone Luau runtime"),
(Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD), "DiscordLuau/discord-luau: A Luau library for creating Discord bots, powered by Lune"),
(Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD), "pesde-pkg/pesde: A package manager for the Luau programming language, supporting multiple runtimes including Roblox and Lune"),
];
for (style, project) in projects {
let parts: Vec<&str> = project.splitn(2, ':').collect();
let (left, right) = if parts.len() == 2 {
(parts[0], parts[1])
} else {
(project, "")
};
let formatted_left = Span::styled(left, style);
let bullet = "";
let indent = " ";
let first_line = if project.len() > area.width as usize - bullet.len() {
let split_point = project
.char_indices()
.take_while(|(i, _)| *i < area.width as usize - bullet.len())
.last()
.map(|(i, _)| i)
.unwrap_or(project.len());
let (first, rest) = project.split_at(split_point);
content.push(Line::from(vec![
Span::from(bullet),
formatted_left,
Span::from(":"),
Span::styled(
first
.trim_start_matches(format!("{left}:").as_str())
.to_string(),
Style::default().fg(Color::White),
),
]));
rest.to_string()
} else {
content.push(Line::from(vec![
Span::from(bullet),
formatted_left,
Span::from(":"),
Span::styled(right.to_string(), Style::default().fg(Color::White)),
]));
String::new()
};
let mut remaining_text = first_line;
while !remaining_text.is_empty() {
if remaining_text.len() > area.width as usize - indent.len() {
let split_point = remaining_text
.char_indices()
.take_while(|(i, _)| *i < area.width as usize - indent.len())
.last()
.map(|(i, _)| i)
.unwrap_or(remaining_text.len());
let (first, rest) = remaining_text.split_at(split_point);
content.push(Line::from(vec![
Span::from(indent),
Span::styled(first.to_string(), Style::default().fg(Color::White)),
]));
remaining_text = rest.to_string();
} else {
content.push(Line::from(vec![
Span::from(indent),
Span::styled(remaining_text.clone(), Style::default().fg(Color::White)),
]));
remaining_text.clear();
}
}
}
content.extend(vec![
Line::from(""),
Line::from(
" I am also a fan of the 8 bit aesthetic and think seals are super adorable :3",
),
]);
Ok(content)
}
/// Generate the content for the "Projects" tab
fn projects_content(&self) -> Vec<Line<'static>> {
vec![Line::from("WIP")]
}
/// Generate the content for the "Blog" tab
fn blog_content(&self) -> Vec<Line<'static>> {
vec![Line::from("coming soon! :^)")]
}
}
impl Component for Content {
fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> Result<()> {
self.command_tx = Some(tx);
Ok(())
}
fn register_config_handler(&mut self, config: Config) -> Result<()> {
self.config = config;
Ok(())
}
fn update(&mut self, action: Action) -> Result<Option<Action>> {
match action {
Action::Tick => {}
Action::Render => {}
_ => {}
}
Ok(None)
}
fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
let 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(())
}
}

View file

@ -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<Option<Action>> {
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(())
}
}

104
src/components/tabs.rs Normal file
View file

@ -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<UnboundedSender<Action>>,
config: Config,
tabs: Vec<&'static str>,
selected_tab: Arc<AtomicUsize>,
}
impl Tabs {
pub fn new(tabs: Vec<&'static str>, selected_tab: Arc<AtomicUsize>) -> 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<Action>) -> Result<()> {
self.command_tx = Some(tx);
Ok(())
}
fn register_config_handler(&mut self, config: Config) -> Result<()> {
self.config = config;
Ok(())
}
fn update(&mut self, action: Action) -> Result<Option<Action>> {
match action {
Action::Tick => {}
Action::Render => {}
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(())
}}

View file

@ -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<Msg>) -> Self {
fn new(session: Handle, channel: Channel<Msg>) -> 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<Arc<Mutex<App>>>,
ssh_tx: mpsc::UnboundedSender<Vec<u8>>,
keystroke_tx: mpsc::UnboundedSender<Vec<u8>>,
resize_tx: mpsc::UnboundedSender<(u16, u16)>,
init_dims_tx: Option<oneshot::Sender<((u16, u16), (u16, u16))>>,
init_dims_rx: Option<oneshot::Receiver<((u16, u16), (u16, u16))>>,
tui: Arc<RwLock<Option<Tui>>>,
}
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)
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<dyn std::error::Error + Send + Sync>> = (|| 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)]

90
src/tui/backend.rs Normal file
View file

@ -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<TermWriter>,
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<Size> {
Ok(Size {
width: self.dims.0,
height: self.dims.1,
})
}
fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
where
I: Iterator<Item = (u16, u16, &'a ratatui::buffer::Cell)>,
{
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<ratatui::prelude::Position> {
self.inner.get_cursor_position()
}
fn set_cursor_position<P: Into<ratatui::prelude::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<ratatui::backend::WindowSize> {
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<TermWriter>;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl DerefMut for SshBackend {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.inner
}
}

View file

@ -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<Backend<TermWriter>>;
pub type Terminal = ratatui::Terminal<SshBackend>;
#[derive(Debug)]
pub struct Tui {
pub terminal: Arc<Mutex<Terminal>>,
pub task: JoinHandle<()>,