Compare commits

..

No commits in common. "main" and "main" have entirely different histories.
main ... main

32 changed files with 1229 additions and 4963 deletions

View file

@ -10,7 +10,6 @@
"<left>": "PrevTab", // Go to the previous tab "<left>": "PrevTab", // Go to the previous tab
"<down>": "SelectNext", // Go to the next selection in options "<down>": "SelectNext", // Go to the next selection in options
"<up>": "SelectPrev", // Go to the previous selection in options "<up>": "SelectPrev", // Go to the previous selection in options
"<enter>": "Continue", // Continue with the current selection
}, },
} }
} }

4539
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -16,20 +16,9 @@ blog = [
"dep:atrium-common", "dep:atrium-common",
"dep:reqwest", "dep:reqwest",
"dep:ipld-core", "dep:ipld-core",
"dep:tui-markdown",
"dep:chrono",
"dep:ratatui-image",
"dep:image"
] ]
[package.metadata.patch]
crates = ["ratatui-image"]
[patch.crates-io]
ratatui-image = { path = "./target/patch/ratatui-image-8.0.1" }
[dependencies] [dependencies]
actix-web = "4.11.0"
anyhow = "1.0.90" anyhow = "1.0.90"
async-trait = "0.1.85" async-trait = "0.1.85"
atrium-api = { version = "0.25.4", optional = true } atrium-api = { version = "0.25.4", optional = true }
@ -38,7 +27,6 @@ atrium-xrpc = { version = "0.12.3", optional = true }
atrium-xrpc-client = { version = "0.5.14", optional = true } atrium-xrpc-client = { version = "0.5.14", optional = true }
better-panic = "0.3.0" better-panic = "0.3.0"
bstr = "1.11.3" bstr = "1.11.3"
chrono = { version = "0.4.41", optional = true }
clap = { version = "4.5.20", features = [ clap = { version = "4.5.20", features = [
"derive", "derive",
"cargo", "cargo",
@ -48,27 +36,22 @@ clap = { version = "4.5.20", features = [
"unstable-styles", "unstable-styles",
] } ] }
color-eyre = "0.6.3" color-eyre = "0.6.3"
config = "0.15.14" config = "0.14.0"
crossterm = { version = "0.28.1", features = ["serde", "event-stream"] } crossterm = { version = "0.28.1", features = ["serde", "event-stream"] }
default_variant = "0.1.0"
derive_deref = "1.1.1" derive_deref = "1.1.1"
directories = "5.0.1" directories = "5.0.1"
figlet-rs = "0.1.5" figlet-rs = "0.1.5"
futures = "0.3.31" futures = "0.3.31"
human-panic = "2.0.2" human-panic = "2.0.2"
image = { version = "0.25.6", optional = true }
indoc = "2.0.5" indoc = "2.0.5"
ipld-core = { version = "0.4.2", optional = true } ipld-core = { version = "0.4.2", optional = true }
json5 = "0.4.1" json5 = "0.4.1"
lazy_static = "1.5.0" lazy_static = "1.5.0"
libc = "0.2.161" libc = "0.2.161"
mime_guess = "2.0.5"
pretty_assertions = "1.4.1" pretty_assertions = "1.4.1"
ratatui = { version = "0.29.0", features = ["serde", "macros"] } ratatui = { version = "0.29.0", features = ["serde", "macros"] }
ratatui-image = { version = "8.0.1", optional = true }
reqwest = { version = "0.12", features = ["rustls-tls"], optional = true } reqwest = { version = "0.12", features = ["rustls-tls"], optional = true }
russh = "0.49.2" russh = "0.49.2"
rust-embed = { version = "8.7.2", features = ["actix"] }
serde = { version = "1.0.211", features = ["derive"] } serde = { version = "1.0.211", features = ["derive"] }
serde_json = "1.0.132" serde_json = "1.0.132"
signal-hook = "0.3.17" signal-hook = "0.3.17"
@ -79,11 +62,9 @@ tokio-util = "0.7.12"
tracing = "0.1.40" tracing = "0.1.40"
tracing-error = "0.2.0" tracing-error = "0.2.0"
tracing-subscriber = { version = "0.3.18", features = ["env-filter", "serde"] } tracing-subscriber = { version = "0.3.18", features = ["env-filter", "serde"] }
tui-markdown = { version = "0.3.5", optional = true }
[build-dependencies] [build-dependencies]
anyhow = "1.0.90" anyhow = "1.0.90"
atrium-codegen = { git = "https://github.com/atrium-rs/atrium.git", rev = "ccc0213" } atrium-codegen = { git = "https://github.com/atrium-rs/atrium.git", rev = "ccc0213" }
patch-crate = "0.1.13"
ssh-key = { version = "0.6.7", features = ["getrandom", "crypto"] } ssh-key = { version = "0.6.7", features = ["getrandom", "crypto"] }
vergen-gix = { version = "1.0.2", features = ["build", "cargo"] } vergen-gix = { version = "1.0.2", features = ["build", "cargo"] }

View file

@ -1,5 +1,4 @@
use std::env; use std::{env, path::PathBuf};
use std::path::PathBuf;
use anyhow::Result; use anyhow::Result;
use ssh_key::{rand_core, Algorithm, EcdsaCurve, LineEnding, PrivateKey}; use ssh_key::{rand_core, Algorithm, EcdsaCurve, LineEnding, PrivateKey};
@ -9,23 +8,24 @@ use vergen_gix::{BuildBuilder, CargoBuilder, Emitter, GixBuilder};
const ATPROTO_LEXICON_DIR: &str = "src/atproto/lexicons"; const ATPROTO_LEXICON_DIR: &str = "src/atproto/lexicons";
#[cfg(feature = "blog")] #[cfg(feature = "blog")]
const ATPROTO_CLIENT_DIR: &str = "src/atproto"; const ATPROTO_CLIENT_DIR: &str = "src/atproto";
const SSH_KEY_ALGOS: &[(&str, Algorithm)] = &[ const SSH_KEY_ALGOS: &[(&'static str, Algorithm)] = &[
("rsa.pem", Algorithm::Rsa { hash: None }), ("rsa.pem", Algorithm::Rsa { hash: None }),
("ed25519.pem", Algorithm::Ed25519), ("ed25519.pem", Algorithm::Ed25519),
("ecdsa.pem", Algorithm::Ecdsa { curve: EcdsaCurve::NistP256 }), (
"ecdsa.pem",
Algorithm::Ecdsa {
curve: EcdsaCurve::NistP256,
},
),
]; ];
fn main() -> Result<()> { fn main() -> Result<()> {
println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=src/atproto/lexicons"); println!("cargo:rerun-if-changed=src/atproto/lexicons");
println!("cargo:rerun-if-changed=Cargo.toml");
println!("cargo:rerun-if-changed=patches/");
patch_crate::run().expect("Failed while patching");
// Generate openSSH host keys // Generate openSSH host keys
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
let mut rng = rand_core::OsRng; let mut rng = rand_core::OsRng::default();
for (file_name, algo) in SSH_KEY_ALGOS { for (file_name, algo) in SSH_KEY_ALGOS {
let path = out_dir.join(file_name); let path = out_dir.join(file_name);
if path.exists() { if path.exists() {
@ -36,8 +36,7 @@ fn main() -> Result<()> {
continue; continue;
} }
let key = let key = PrivateKey::random(&mut rng, algo.to_owned()).map_err(anyhow::Error::from)?;
PrivateKey::random(&mut rng, algo.to_owned()).map_err(anyhow::Error::from)?;
key.write_openssh_file(&path, LineEnding::default())?; key.write_openssh_file(&path, LineEnding::default())?;
} }

View file

@ -1,21 +0,0 @@
diff --git a/src/picker.rs b/src/picker.rs
index a8f4889..b5eaf5a 100644
--- a/src/picker.rs
+++ b/src/picker.rs
@@ -44,11 +44,11 @@ const DEFAULT_BACKGROUND: Rgba<u8> = Rgba([0, 0, 0, 0]);
#[derive(Clone, Debug)]
pub struct Picker {
- font_size: FontSize,
- protocol_type: ProtocolType,
- background_color: Rgba<u8>,
- is_tmux: bool,
- capabilities: Vec<Capability>,
+ pub font_size: FontSize,
+ pub protocol_type: ProtocolType,
+ pub background_color: Rgba<u8>,
+ pub is_tmux: bool,
+ pub capabilities: Vec<Capability>,
}
/// Serde-friendly protocol-type enum for [Picker].

View file

@ -1,3 +0,0 @@
[toolchain]
channel = "nightly-2025-03-28"
components = ["clippy", "rust-analyzer", "cargo", "rustc"]

View file

@ -1,13 +0,0 @@
edition = "2021"
use_small_heuristics = "Max"
max_width = 95
newline_style = "Unix"
indent_style = "Block"
hard_tabs = false
format_strings = true
brace_style = "PreferSameLine"
chain_width = 95
imports_granularity = "Module"

View file

@ -1,11 +1,7 @@
use std::fmt; use serde::{Deserialize, Serialize};
use serde::de::{self, Visitor};
use serde::{Deserialize, Deserializer, Serialize};
use strum::Display; use strum::Display;
#[derive(Debug, Clone, PartialEq, Eq, Display, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Display, Serialize, Deserialize)]
#[allow(dead_code)]
pub enum Action { pub enum Action {
Tick, Tick,
Render, Render,
@ -24,65 +20,4 @@ pub enum Action {
// Selection management // Selection management
SelectNext, SelectNext,
SelectPrev, SelectPrev,
Continue(Option<usize>),
}
// HACK: should probably make this nicer
impl<'de> Deserialize<'de> for Action {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>, {
struct ActionVisitor;
impl<'de> Visitor<'de> for ActionVisitor {
type Value = Action;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "an Action enum variant")
}
fn visit_str<E>(self, v: &str) -> Result<Action, E>
where
E: de::Error, {
if v == "Continue" {
Ok(Action::Continue(None))
} else {
// fallback: let serde handle all other strings
#[derive(Deserialize)]
enum Helper {
Tick,
Render,
Suspend,
Resume,
Quit,
ClearScreen,
Help,
NextTab,
PrevTab,
SelectNext,
SelectPrev,
}
let helper: Helper = serde_json::from_str(&format!("\"{}\"", v))
.map_err(|_| de::Error::unknown_variant(v, &["Continue"]))?;
Ok(match helper {
Helper::Tick => Action::Tick,
Helper::Render => Action::Render,
Helper::Suspend => Action::Suspend,
Helper::Resume => Action::Resume,
Helper::Quit => Action::Quit,
Helper::ClearScreen => Action::ClearScreen,
Helper::Help => Action::Help,
Helper::NextTab => Action::NextTab,
Helper::PrevTab => Action::PrevTab,
Helper::SelectNext => Action::SelectNext,
Helper::SelectPrev => Action::SelectPrev,
})
}
}
}
deserializer.deserialize_any(ActionVisitor)
}
} }

View file

@ -1,36 +1,36 @@
use std::sync::atomic::AtomicUsize; use std::sync::{atomic::AtomicUsize, Arc};
use std::sync::Arc;
use std::time::Duration;
use color_eyre::{eyre, Result}; use color_eyre::{eyre, Result};
use crossterm::event::{KeyCode, KeyEvent}; use crossterm::event::{KeyCode, KeyEvent};
use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; use ratatui::{
use ratatui::style::{Color, Modifier, Style}; layout::{Constraint, Direction, Layout},
use ratatui::text::{Line, Span}; prelude::Rect,
use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; style::{Color, Modifier, Style},
text::{Line, Span},
widgets::Paragraph,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio::sync::{mpsc, Mutex, RwLock}; use tokio::{
use tokio::task::block_in_place; sync::{mpsc, Mutex, RwLock},
task::block_in_place,
};
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use tracing::debug; use tracing::{debug, info};
use crate::action::Action; use crate::{
use crate::components::*; action::Action,
use crate::config::Config; components::*,
use crate::keycode::KeyCodeExt; config::Config,
use crate::tui::terminal::{TerminalInfo, TerminalKind, UnsupportedReason}; keycode::KeyCodeExt,
use crate::tui::{Event, Terminal, Tui}; tui::{Event, Terminal, Tui},
};
pub struct App { pub struct App {
config: Config, config: Config,
tick_rate: f64, tick_rate: f64,
frame_rate: f64, frame_rate: f64,
terminal_info: Arc<RwLock<TerminalInfo>>,
should_quit: bool, should_quit: bool,
should_suspend: bool, should_suspend: bool,
needs_resize: bool,
mode: Mode, mode: Mode,
last_tick_key_events: Vec<KeyEvent>, last_tick_key_events: Vec<KeyEvent>,
action_tx: mpsc::UnboundedSender<Action>, action_tx: mpsc::UnboundedSender<Action>,
@ -44,7 +44,7 @@ pub struct App {
content: Arc<Mutex<Content>>, content: Arc<Mutex<Content>>,
cat: Arc<Mutex<Cat>>, cat: Arc<Mutex<Cat>>,
#[cfg(feature = "blog")] #[cfg(feature = "blog")]
blog_posts: Arc<Mutex<BlogPosts>>, selection_list: Arc<Mutex<SelectionList>>,
} }
#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
@ -54,10 +54,7 @@ pub enum Mode {
} }
impl App { impl App {
pub const MIN_TUI_DIMS: (u16, u16) = (105, 25);
pub fn new( pub fn new(
terminal_info: Arc<RwLock<TerminalInfo>>,
tick_rate: f64, tick_rate: f64,
frame_rate: f64, frame_rate: f64,
keystroke_rx: mpsc::UnboundedReceiver<Vec<u8>>, keystroke_rx: mpsc::UnboundedReceiver<Vec<u8>>,
@ -78,18 +75,15 @@ impl App {
#[cfg(feature = "blog")] #[cfg(feature = "blog")]
let rt = tokio::runtime::Handle::current(); let rt = tokio::runtime::Handle::current();
#[cfg(feature = "blog")] #[cfg(feature = "blog")]
let blog_posts = Arc::new(Mutex::new(BlogPosts::new( let selection_list = Arc::new(Mutex::new(SelectionList::new(
rt.block_on(content.try_lock()?.blog_content())?, rt.block_on(content.blocking_lock().blog_content())?,
))); )));
Ok(Self { Ok(Self {
terminal_info,
tick_rate, tick_rate,
frame_rate, frame_rate,
should_quit: false, should_quit: false,
should_suspend: false, should_suspend: false,
needs_resize: false,
config: Config::new()?, config: Config::new()?,
mode: Mode::Home, mode: Mode::Home,
last_tick_key_events: Vec::new(), last_tick_key_events: Vec::new(),
@ -103,7 +97,7 @@ impl App {
content, content,
cat, cat,
#[cfg(feature = "blog")] #[cfg(feature = "blog")]
blog_posts, selection_list,
}) })
} }
@ -113,46 +107,43 @@ impl App {
tui: Arc<RwLock<Option<Tui>>>, tui: Arc<RwLock<Option<Tui>>>,
) -> Result<()> { ) -> Result<()> {
let mut tui = tui.write().await; let mut tui = tui.write().await;
let tui = tui.get_or_insert( let mut tui = tui.get_or_insert(
Tui::new(term)?.tick_rate(self.tick_rate).frame_rate(self.frame_rate), Tui::new(term)?
.tick_rate(self.tick_rate)
.frame_rate(self.frame_rate),
); );
// Force the dimensions to be validated before rendering anything by sending a `Resize` event
let term_size = tui.terminal.try_lock()?.size()?;
tui.event_tx.send(Event::Resize(term_size.width, term_size.height))?;
// Blocking initialization logic for tui and components // Blocking initialization logic for tui and components
block_in_place(|| { block_in_place(|| {
tui.enter()?; tui.enter()?;
// Register action handlers // Register action handlers
self.tabs.try_lock()?.register_action_handler(self.action_tx.clone())?; self.tabs
self.content.try_lock()?.register_action_handler(self.action_tx.clone())?; .try_lock()?
self.cat.try_lock()?.register_action_handler(self.action_tx.clone())?; .register_action_handler(self.action_tx.clone())?;
#[cfg(feature = "blog")] self.content
self.blog_posts.try_lock()?.register_action_handler(self.action_tx.clone())?; .try_lock()?
.register_action_handler(self.action_tx.clone())?;
self.cat
.try_lock()?
.register_action_handler(self.action_tx.clone())?;
// Register config handlers // Register config handlers
self.tabs.try_lock()?.register_config_handler(self.config.clone())?; self.tabs
self.content.try_lock()?.register_config_handler(self.config.clone())?; .try_lock()?
self.cat.try_lock()?.register_config_handler(self.config.clone())?; .register_config_handler(self.config.clone())?;
#[cfg(feature = "blog")] self.content
self.blog_posts.try_lock()?.register_config_handler(self.config.clone())?; .try_lock()?
.register_config_handler(self.config.clone())?;
while let TerminalKind::Unsupported(UnsupportedReason::Unprobed) = self.cat
self.terminal_info.blocking_read().kind() .try_lock()?
{ .register_config_handler(self.config.clone())?;
tracing::debug!("Waiting for terminal kind to be probed...");
std::thread::sleep(Duration::from_millis(100));
}
// Initialize components // Initialize components
let size = tui.terminal.try_lock()?.size()?; let size = tui.terminal.try_lock()?.size()?;
self.tabs.try_lock()?.init(self.terminal_info.clone(), size)?; self.tabs.try_lock()?.init(size)?;
self.content.try_lock()?.init(self.terminal_info.clone(), size)?; self.content.try_lock()?.init(size)?;
self.cat.try_lock()?.init(self.terminal_info.clone(), size)?; self.cat.try_lock()?.init(size)?;
#[cfg(feature = "blog")]
self.blog_posts.try_lock()?.init(self.terminal_info.clone(), size)?;
Ok::<_, eyre::Error>(()) Ok::<_, eyre::Error>(())
})?; })?;
@ -160,8 +151,8 @@ impl App {
let action_tx = self.action_tx.clone(); let action_tx = self.action_tx.clone();
let mut resume_tx: Option<Arc<CancellationToken>> = None; let mut resume_tx: Option<Arc<CancellationToken>> = None;
loop { loop {
self.handle_events(tui).await?; self.handle_events(&mut tui).await?;
block_in_place(|| self.handle_actions(tui))?; block_in_place(|| self.handle_actions(&mut tui))?;
if self.should_suspend { if self.should_suspend {
if let Some(ref tx) = resume_tx { if let Some(ref tx) = resume_tx {
tx.cancel(); tx.cancel();
@ -226,13 +217,13 @@ impl App {
}; };
match keymap.get(&vec![key]) { match keymap.get(&vec![key]) {
Some(action) => { Some(action) => {
debug!("Got action: {action:?}"); info!("Got action: {action:?}");
action_tx.send(action.clone())?; action_tx.send(action.clone())?;
} }
_ => { _ => {
self.last_tick_key_events.push(key); self.last_tick_key_events.push(key);
if let Some(action) = keymap.get(&self.last_tick_key_events) { if let Some(action) = keymap.get(&self.last_tick_key_events) {
debug!("Got action: {action:?}"); info!("Got action: {action:?}");
action_tx.send(action.clone())?; action_tx.send(action.clone())?;
} }
} }
@ -249,18 +240,11 @@ impl App {
Action::Tick => { Action::Tick => {
self.last_tick_key_events.drain(..); self.last_tick_key_events.drain(..);
} }
Action::Quit => { Action::Quit => self.should_quit = true,
if !self.blog_posts.try_lock()?.is_in_post() {
self.should_quit = true;
}
}
Action::Suspend => self.should_suspend = true, Action::Suspend => self.should_suspend = true,
Action::Resume => self.should_suspend = false, Action::Resume => self.should_suspend = false,
Action::ClearScreen => tui.terminal.try_lock()?.clear()?, Action::ClearScreen => tui.terminal.try_lock()?.clear()?,
Action::Resize(w, h) => { Action::Resize(w, h) => self.resize(tui, w, h)?,
self.needs_resize = w < Self::MIN_TUI_DIMS.0 || h < Self::MIN_TUI_DIMS.1;
self.resize(tui, w, h)?;
}
Action::Render => self.render(tui)?, Action::Render => self.render(tui)?,
_ => {} _ => {}
} }
@ -277,7 +261,7 @@ impl App {
} }
#[cfg(feature = "blog")] #[cfg(feature = "blog")]
if let Some(action) = self.blog_posts.try_lock()?.update(action.clone())? { if let Some(action) = self.selection_list.try_lock()?.update(action.clone())? {
self.action_tx.send(action)?; self.action_tx.send(action)?;
} }
} }
@ -294,55 +278,7 @@ impl App {
} }
fn render(&mut self, tui: &mut Tui) -> Result<()> { fn render(&mut self, tui: &mut Tui) -> Result<()> {
let mut term = tui.terminal.try_lock()?; tui.terminal.try_lock()?.try_draw(|frame| {
if self.needs_resize {
term.draw(|frame| {
let size = frame.area();
let error_message = format!(
"window size must be at least {}x{}, currently {}x{}",
Self::MIN_TUI_DIMS.0,
Self::MIN_TUI_DIMS.1,
size.width,
size.height
);
let error_width = error_message.chars().count().try_into().unwrap_or(55);
let error_height = 5;
#[rustfmt::skip]
let area = Block::default()
.borders(Borders::all())
.style(Style::new().fg(Color::White))
.inner(Rect::new(
size.width
.checked_sub(error_width)
.and_then(|n| n.checked_div(2))
.unwrap_or_default(),
size.height
.checked_sub(error_height)
.and_then(|n| n.checked_div(2))
.unwrap_or_default(),
if error_width > size.width { u16::MIN } else { error_width },
if size.height > error_height { error_height } else { size.height },
));
frame.render_widget(Clear, area);
frame.render_widget(
Paragraph::new(
Line::from(error_message.clone()).style(
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
)
.alignment(Alignment::Center)
.wrap(Wrap { trim: false }),
area,
);
})?;
return Ok(());
}
term.try_draw(|frame| {
let chunks = Layout::default() let chunks = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)].as_ref()) .constraints([Constraint::Length(3), Constraint::Min(0)].as_ref())
@ -351,16 +287,26 @@ impl App {
// Render the domain name text // Render the domain name text
let title = Paragraph::new(Line::from(Span::styled( let title = Paragraph::new(Line::from(Span::styled(
"devcomp.xyz ", "devcomp.xyz ",
Style::default().fg(Color::White).add_modifier(Modifier::BOLD), Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD),
))); )));
frame.render_widget( frame.render_widget(
title, title,
Rect { x: chunks[0].x + 2, y: chunks[0].y + 2, width: 14, height: 1 }, Rect {
x: chunks[0].x + 2,
y: chunks[0].y + 2,
width: 14,
height: 1,
},
); );
// Render the tabs // Render the tabs
let mut tabs = self.tabs.try_lock().map_err(std::io::Error::other)?; let mut tabs = self
.tabs
.try_lock()
.map_err(|err| std::io::Error::other(err))?;
tabs.draw( tabs.draw(
frame, frame,
@ -371,7 +317,7 @@ impl App {
height: chunks[0].height, height: chunks[0].height,
}, },
) )
.map_err(std::io::Error::other)?; .map_err(|err| std::io::Error::other(err))?;
// Render the content // Render the content
let content_rect = Rect { let content_rect = Rect {
@ -383,16 +329,16 @@ impl App {
self.content self.content
.try_lock() .try_lock()
.map_err(std::io::Error::other)? .map_err(|err| std::io::Error::other(err))?
.draw(frame, content_rect) .draw(frame, content_rect)
.map_err(std::io::Error::other)?; .map_err(|err| std::io::Error::other(err))?;
// Render the eepy cat :3 // Render the eepy cat :3
self.cat self.cat
.try_lock() .try_lock()
.map_err(std::io::Error::other)? .map_err(|err| std::io::Error::other(err))?
.draw(frame, frame.area()) .draw(frame, frame.area())
.map_err(std::io::Error::other)?; .map_err(|err| std::io::Error::other(err))?;
if tabs.current_tab() == 2 { if tabs.current_tab() == 2 {
let mut content_rect = content_rect; let mut content_rect = content_rect;
@ -404,11 +350,11 @@ impl App {
#[cfg(feature = "blog")] #[cfg(feature = "blog")]
{ {
// Render the post selection list if the blog tab is selected // Render the post selection list if the blog tab is selected
self.blog_posts self.selection_list
.try_lock() .try_lock()
.map_err(std::io::Error::other)? .map_err(|err| std::io::Error::other(err))?
.draw(frame, content_rect) .draw(frame, content_rect)
.map_err(std::io::Error::other)?; .map_err(|err| std::io::Error::other(err))?;
} }
#[cfg(not(feature = "blog"))] #[cfg(not(feature = "blog"))]
@ -416,8 +362,7 @@ impl App {
// If blog feature is not enabled, render a placeholder // If blog feature is not enabled, render a placeholder
content_rect.height = 1; content_rect.height = 1;
let placeholder = Paragraph::new( let placeholder = Paragraph::new(
"Blog feature is disabled. Enable the `blog` feature to view this \ "Blog feature is disabled. Enable the `blog` feature to view this tab.",
tab.",
) )
.style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)); .style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD));

View file

@ -5,18 +5,21 @@ pub mod com;
pub mod blog { pub mod blog {
use std::str::FromStr as _; use std::str::FromStr as _;
use atrium_api::agent::atp_agent::store::MemorySessionStore;
use atrium_api::agent::atp_agent::CredentialSession;
use atrium_api::agent::Agent;
use atrium_api::com::atproto::repo::list_records;
use atrium_api::com::atproto::server::create_session::OutputData as SessionOutputData; use atrium_api::com::atproto::server::create_session::OutputData as SessionOutputData;
use atrium_api::types::string::{AtIdentifier, Handle}; use atrium_api::{
use atrium_api::types::{Collection as _, Object, Unknown}; agent::{
use atrium_common::store::memory::MemoryStore; atp_agent::{store::MemorySessionStore, CredentialSession},
use atrium_common::store::Store; Agent,
},
com::atproto::repo::list_records,
types::{
string::{AtIdentifier, Handle},
Collection as _, Object, Unknown,
},
};
use atrium_common::store::{memory::MemoryStore, Store};
use atrium_xrpc_client::reqwest::ReqwestClient; use atrium_xrpc_client::reqwest::ReqwestClient;
use color_eyre::eyre::eyre; use color_eyre::{eyre::eyre, Result};
use color_eyre::Result;
use ipld_core::ipld::Ipld; use ipld_core::ipld::Ipld;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use tokio::time::{Duration, Instant}; use tokio::time::{Duration, Instant};
@ -66,7 +69,7 @@ pub mod blog {
.com .com
.atproto .atproto
.repo .repo
.list_records(list_records::Parameters { .list_records(Object::from(list_records::Parameters {
extra_data: Ipld::Null, extra_data: Ipld::Null,
data: list_records::ParametersData { data: list_records::ParametersData {
collection: com::whtwnd::blog::Entry::nsid(), collection: com::whtwnd::blog::Entry::nsid(),
@ -78,7 +81,7 @@ pub mod blog {
.map_err(|_| eyre!("Invalid repo handle"))?, .map_err(|_| eyre!("Invalid repo handle"))?,
), ),
}, },
}) }))
.await? .await?
.records; .records;
@ -97,7 +100,9 @@ pub mod blog {
.collect::<Result<Vec<com::whtwnd::blog::entry::Record>>>()?; .collect::<Result<Vec<com::whtwnd::blog::entry::Record>>>()?;
for (i, post) in posts.iter().enumerate() { for (i, post) in posts.iter().enumerate() {
POSTS_CACHE_STORE.set(i, (Instant::now(), post.clone())).await?; POSTS_CACHE_STORE
.set(i, (Instant::now(), post.clone()))
.await?;
} }
Ok(posts) Ok(posts)

View file

@ -18,28 +18,28 @@ pub struct Cli {
#[arg(short = 'H', long, value_name = "ADDRESS", default_value_t = String::from("127.0.0.1"))] #[arg(short = 'H', long, value_name = "ADDRESS", default_value_t = String::from("127.0.0.1"))]
pub host: String, pub host: String,
/// The port to start the SSH server on /// The port to start the SSH server on
#[arg(short = 'P', long, value_name = "PORT", default_value_t = 2222)] #[arg(short, long, value_name = "PORT", default_value_t = 2222)]
pub ssh_port: u16, pub port: u16,
/// The port to start the web server on
#[arg(short = 'p', long, value_name = "PORT", default_value_t = 80)]
pub web_port: u16,
} }
const VERSION_MESSAGE: &str = concat!(
env!("CARGO_PKG_VERSION"),
"-",
env!("VERGEN_GIT_DESCRIBE"),
" (",
env!("VERGEN_BUILD_DATE"),
")"
);
pub fn version() -> String { pub fn version() -> String {
let author = clap::crate_authors!(); let author = clap::crate_authors!();
let version_message = format!(
"v{}-{} ({}, {})",
env!("CARGO_PKG_VERSION"),
&env!("VERGEN_GIT_SHA")[..7],
env!("VERGEN_GIT_BRANCH"),
env!("VERGEN_BUILD_DATE")
);
// let current_exe_path = PathBuf::from(clap::crate_name!()).display().to_string();
let config_dir_path = get_config_dir().display().to_string(); let config_dir_path = get_config_dir().display().to_string();
let data_dir_path = get_data_dir().display().to_string(); let data_dir_path = get_data_dir().display().to_string();
formatdoc! {" formatdoc! {"
{version_message} {VERSION_MESSAGE}
Authors: {author} Authors: {author}

View file

@ -1,35 +1,28 @@
use std::sync::Arc;
use color_eyre::Result; use color_eyre::Result;
use crossterm::event::{KeyEvent, MouseEvent}; use crossterm::event::{KeyEvent, MouseEvent};
use ratatui::layout::{Rect, Size}; use ratatui::{
use ratatui::Frame; layout::{Rect, Size},
Frame,
};
use tokio::sync::mpsc::UnboundedSender; use tokio::sync::mpsc::UnboundedSender;
use tokio::sync::RwLock;
use crate::action::Action; use crate::{action::Action, config::Config, tui::Event};
use crate::config::Config;
use crate::tui::terminal::TerminalInfo;
use crate::tui::Event;
// //
// Component re-exports // Component re-exports
// //
#[cfg(feature = "blog")]
mod blog; mod tabs;
mod cat;
mod content; mod content;
mod cat;
#[cfg(feature = "blog")] #[cfg(feature = "blog")]
mod selection_list; mod selection_list;
mod tabs;
#[cfg(feature = "blog")] pub use tabs::*;
pub use blog::*;
pub use cat::*;
pub use content::*; pub use content::*;
pub use cat::*;
#[cfg(feature = "blog")] #[cfg(feature = "blog")]
pub use selection_list::*; pub use selection_list::*;
pub use tabs::*;
/// `Component` is a trait that represents a visual and interactive element of the user interface. /// `Component` is a trait that represents a visual and interactive element of the user interface.
/// ///
@ -62,7 +55,7 @@ pub trait Component: Send {
let _ = config; // to appease clippy let _ = config; // to appease clippy
Ok(()) Ok(())
} }
/// Initialize the component with a specified area and terminal kind if necessary. /// Initialize the component with a specified area if necessary.
/// ///
/// # Arguments /// # Arguments
/// ///
@ -71,8 +64,8 @@ pub trait Component: Send {
/// # Returns /// # Returns
/// ///
/// * `Result<()>` - An Ok result or an error. /// * `Result<()>` - An Ok result or an error.
fn init(&mut self, term_info: Arc<RwLock<TerminalInfo>>, area: Size) -> Result<()> { fn init(&mut self, area: Size) -> Result<()> {
let _ = (area, term_info); // to appease clippy let _ = area; // to appease clippy
Ok(()) Ok(())
} }
/// Handle incoming events and produce actions if necessary. /// Handle incoming events and produce actions if necessary.

View file

@ -1,195 +0,0 @@
use std::io::{BufReader, Cursor};
use std::sync::Arc;
use color_eyre::eyre::eyre;
use color_eyre::Result;
use image::{ImageReader, Rgba};
use ratatui::layout::{Constraint, Flex, Layout, Rect, Size};
use ratatui::prelude::*;
use ratatui::widgets::*;
use ratatui_image::picker::{Picker, ProtocolType};
use ratatui_image::protocol::StatefulProtocol;
use ratatui_image::{Resize, StatefulImage};
use tokio::sync::mpsc::UnboundedSender;
use tokio::sync::RwLock;
use crate::action::Action;
use crate::com;
use crate::com::whtwnd::blog::defs::Ogp;
use crate::components::{Component, SelectionList};
use crate::tui::terminal::{TerminalInfo, TerminalKind, UnsupportedReason, DEFAULT_FONT_SIZE};
pub type Post = Arc<com::whtwnd::blog::entry::Record>;
pub struct BlogPosts {
list: SelectionList<Post>,
posts: Vec<Post>,
image_renderer: Option<Picker>,
in_post: (Option<StatefulProtocol>, Option<usize>),
}
impl BlogPosts {
pub fn new(posts: Vec<Post>) -> Self {
let posts_ref = posts.to_vec();
Self {
list: SelectionList::new(posts),
image_renderer: Some(Picker {
font_size: DEFAULT_FONT_SIZE,
protocol_type: ProtocolType::Halfblocks,
background_color: Rgba([0, 0, 0, 0]),
is_tmux: false, // FIXME: any way to figure this out?
capabilities: vec![],
}),
posts: posts_ref,
in_post: (None, None),
}
}
pub fn is_in_post(&self) -> bool {
self.in_post.1.is_some()
}
async fn header_image(&self, img: Ogp) -> Result<StatefulProtocol> {
if let Some(picker) = &self.image_renderer {
let img_blob = reqwest::get(img.url)
.await?
.bytes()
.await?
.iter()
.cloned()
.collect::<Vec<u8>>();
let dyn_img = ImageReader::new(BufReader::new(Cursor::new(img_blob)))
.with_guessed_format()?
.decode()?;
let sized_img = picker.new_resize_protocol(dyn_img);
return Ok(sized_img);
}
Err(eyre!("No image supported renderer initialized"))
}
}
impl Component for BlogPosts {
fn init(&mut self, term_info: Arc<RwLock<TerminalInfo>>, _: Size) -> Result<()> {
let locked_info = term_info.blocking_read().clone();
if matches!(locked_info.kind(), TerminalKind::Unsupported(UnsupportedReason::Unsized))
{
self.image_renderer = None;
}
if let Some(picker) = &mut self.image_renderer {
picker.capabilities = locked_info.kind().capabilities();
picker.protocol_type = locked_info.kind().as_protocol();
picker.font_size = locked_info.font_size();
tracing::info!(
"Using {:?} rendering protocol for blog image renderer, font size: {:?}",
picker.protocol_type(),
picker.font_size(),
);
}
Ok(())
}
fn register_config_handler(&mut self, config: crate::config::Config) -> Result<()> {
self.list.register_config_handler(config)
}
fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> Result<()> {
self.list.register_action_handler(tx)
}
fn update(&mut self, action: Action) -> Result<Option<Action>> {
match self.list.update(action.clone())?.unwrap() {
// safe to unwrap, guaranteed to not be `None`
Action::Tick => {}
Action::Render => {}
// FIXME: do we reload the image on every single render of a post?
Action::Quit | Action::PrevTab | Action::NextTab => self.in_post = (None, None),
Action::Continue(post_id) => self.in_post.1 = post_id,
_ => {}
};
Ok(None)
}
fn draw(
&mut self,
frame: &mut ratatui::Frame,
area: ratatui::prelude::Rect,
) -> Result<()> {
if let Some(post_id_inner) = self.in_post.1 {
let post = self
.posts
.get(post_id_inner)
.ok_or(eyre!("Current post apparently doesn't exist"))?;
let post_body = post.title.clone().map_or(post.content.clone(), |title| {
format!("# {}\n\n{}", title, post.content)
});
let post_content_text = tui_markdown::from_str(&post_body);
// FIXME: content in the body often overlaps with the `Cat` component and gets
// formatted weirdly. maybe deal with that at some point? real solution is probably a
// refactor to use `Layout`s instead of rolling our own layout logic
if let Some(img) = self.in_post.0.as_mut() {
// Render prefetched image on current draw call
let [image_area, text_area] =
Layout::vertical([Constraint::Percentage(40), Constraint::Percentage(60)])
.flex(Flex::SpaceBetween)
.vertical_margin(2)
.areas(area);
let resized_img = img.size_for(Resize::Fit(None), image_area);
let [image_area] = Layout::horizontal([Constraint::Length(resized_img.width)])
.flex(Flex::Center)
.areas(image_area);
frame.render_stateful_widget(StatefulImage::default(), image_area, img);
frame.render_widget(
Paragraph::new(post_content_text).wrap(Wrap { trim: true }),
text_area,
);
} else if self.image_renderer.is_some() {
// Image not cached, load image and skip rendering for current draw call
if let Some(ref post_ogp) = post.ogp {
let rt = tokio::runtime::Handle::current();
let img =
rt.block_on(async { self.header_image(post_ogp.clone()).await })?;
self.in_post.0 = Some(img);
} else {
frame.render_widget(
post_content_text,
Rect::new(area.x + 1, area.y + 1, area.width, area.height),
);
}
} else if let Some(ref post_ogp) = post.ogp {
// No image rendering capabilities, only display text
let img_url = super::truncate(&post_ogp.url, area.width as usize / 3);
let url_widget = Line::from(img_url).centered().style(
Style::default()
.add_modifier(Modifier::BOLD | Modifier::ITALIC)
.fg(Color::Yellow),
);
frame.render_widget(
url_widget,
Rect::new(area.x + 1, area.y + 1, area.width, area.height),
);
frame.render_widget(
post_content_text,
Rect::new(area.x + 3, area.y + 3, area.width, area.height),
);
}
} else {
self.list.draw(frame, area)?;
}
Ok(())
}
}

View file

@ -1,12 +1,10 @@
use color_eyre::Result; use color_eyre::Result;
use indoc::indoc; use indoc::indoc;
use ratatui::prelude::*; use ratatui::{prelude::*, widgets::*};
use ratatui::widgets::*;
use tokio::sync::mpsc::UnboundedSender; use tokio::sync::mpsc::UnboundedSender;
use super::Component; use super::Component;
use crate::action::Action; use crate::{action::Action, config::Config};
use crate::config::Config;
const CAT_ASCII_ART: &str = indoc! {r#" const CAT_ASCII_ART: &str = indoc! {r#"
|\__/,| (`\ |\__/,| (`\
@ -54,7 +52,12 @@ impl Component for Cat {
.fg(Color::Magenta) .fg(Color::Magenta)
.add_modifier(Modifier::SLOW_BLINK | Modifier::BOLD), .add_modifier(Modifier::SLOW_BLINK | Modifier::BOLD),
), ),
Rect { x: area.width - 17, y: area.height - 4, width: 16, height: 6 }, Rect {
x: area.width - 17,
y: area.height - 4,
width: 16,
height: 6,
},
); );
Ok(()) Ok(())

View file

@ -1,24 +1,15 @@
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{
use std::sync::Arc; atomic::{AtomicUsize, Ordering},
Arc,
};
use color_eyre::eyre::eyre; use color_eyre::{eyre::eyre, Result};
use color_eyre::Result;
use figlet_rs::FIGfont; use figlet_rs::FIGfont;
use ratatui::prelude::*; use ratatui::{prelude::*, widgets::*};
use ratatui::widgets::*;
use tokio::sync::mpsc::UnboundedSender; use tokio::sync::mpsc::UnboundedSender;
use super::Component; use super::Component;
use crate::action::Action; use crate::{action::Action, config::Config};
#[cfg(feature = "blog")]
use crate::components::Post;
use crate::config::Config;
pub(super) fn truncate(s: &str, max: usize) -> String {
s.char_indices()
.find(|(idx, ch)| idx + ch.len_utf8() > max)
.map_or(s.to_string(), |(idx, _)| s[..idx].to_string() + "...")
}
#[derive(Default)] #[derive(Default)]
pub struct Content { pub struct Content {
@ -29,20 +20,25 @@ pub struct Content {
impl Content { impl Content {
pub fn new(selected_tab: Arc<AtomicUsize>) -> Self { pub fn new(selected_tab: Arc<AtomicUsize>) -> Self {
Self { selected_tab, ..Default::default() } Self {
selected_tab,
..Default::default()
}
} }
/// Generate the content for the "About" tab /// Generate the content for the "About" tab
fn about_content(&self, area: Rect) -> Result<Vec<Line<'static>>> { fn about_content(&self, area: Rect) -> Result<Vec<Line<'static>>> {
let greetings_header = let greetings_header = FIGfont::from_content(include_str!("../../assets/drpepper.flf"))
FIGfont::from_content(include_str!("../../assets/drpepper.flf")) .map_err(|err| eyre!(err))?
.map_err(|err| eyre!(err))? .convert("hiya!")
.convert("hiya!") .ok_or(eyre!("Failed to create figlet header for about page"))?
.ok_or(eyre!("Failed to create figlet header for about page"))? .to_string();
.to_string();
let lines: Vec<String> = let lines: Vec<String> = greetings_header
greetings_header.trim_end_matches('\n').split('\n').map(String::from).collect(); .trim_end_matches('\n')
.split('\n')
.map(String::from)
.collect();
let mut content = lines let mut content = lines
.iter() .iter()
@ -53,15 +49,11 @@ impl Content {
Span::from(" "), Span::from(" "),
Span::from(line.clone()), Span::from(line.clone()),
Span::from(" I'm Erica ("), Span::from(" I'm Erica ("),
Span::styled( Span::styled("she/they", Style::default().add_modifier(Modifier::ITALIC)),
"she/they",
Style::default().add_modifier(Modifier::ITALIC),
),
Span::from("), and I make scalable systems or something. IDFK."), Span::from("), and I make scalable systems or something. IDFK."),
]); ]);
} }
Line::raw(format!(" {}", line)) Line::raw(format!(" {}", line)).style(Style::default().add_modifier(Modifier::BOLD))
.style(Style::default().add_modifier(Modifier::BOLD))
}) })
.collect::<Vec<Line<'static>>>(); .collect::<Vec<Line<'static>>>();
@ -86,33 +78,28 @@ impl Content {
Span::from("."), Span::from("."),
]), ]),
Line::from(""), Line::from(""),
Line::from( Line::from(" I am an avid believer of open-source software, and contribute to a few projects such as:"),
" I am an avid believer of open-source software, and contribute to a few \
projects such as:",
),
]); ]);
let projects = vec![ let projects = vec![
( (Style::default()
Style::default().fg(Color::LightMagenta).add_modifier(Modifier::BOLD), .fg(Color::LightMagenta)
"lune-org/lune: A standalone Luau runtime", .add_modifier(Modifier::BOLD), "lune-org/lune: A standalone Luau runtime"),
), (Style::default()
( .fg(Color::Blue)
Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD), .add_modifier(Modifier::BOLD), "DiscordLuau/discord-luau: A Luau library for creating Discord bots, powered by Lune"),
"DiscordLuau/discord-luau: A Luau library for creating Discord bots, powered \ (Style::default()
by Lune", .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"),
(
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 { for (style, project) in projects {
let parts: Vec<&str> = project.splitn(2, ':').collect(); let parts: Vec<&str> = project.splitn(2, ':').collect();
let (left, right) = let (left, right) = if parts.len() == 2 {
if parts.len() == 2 { (parts[0], parts[1]) } else { (project, "") }; (parts[0], parts[1])
} else {
(project, "")
};
let formatted_left = Span::styled(left, style); let formatted_left = Span::styled(left, style);
@ -132,7 +119,9 @@ impl Content {
formatted_left, formatted_left,
Span::from(":"), Span::from(":"),
Span::styled( Span::styled(
first.trim_start_matches(format!("{left}:").as_str()).to_string(), first
.trim_start_matches(format!("{left}:").as_str())
.to_string(),
Style::default().fg(Color::White), Style::default().fg(Color::White),
), ),
])); ]));
@ -165,10 +154,7 @@ impl Content {
} else { } else {
content.push(Line::from(vec![ content.push(Line::from(vec![
Span::from(indent), Span::from(indent),
Span::styled( Span::styled(remaining_text.clone(), Style::default().fg(Color::White)),
remaining_text.clone(),
Style::default().fg(Color::White),
),
])); ]));
remaining_text.clear(); remaining_text.clear();
} }
@ -178,14 +164,12 @@ impl Content {
content.extend(vec![ content.extend(vec![
Line::from(""), Line::from(""),
Line::from( Line::from(
" I am also a fan of the 8 bit aesthetic and think seals are super adorable \ " I am also a fan of the 8 bit aesthetic and think seals are super adorable :3",
:3",
), ),
]); ]);
Ok(content) Ok(content)
} }
/// Generate the content for the "Projects" tab /// Generate the content for the "Projects" tab
fn projects_content(&self) -> Vec<Line<'static>> { fn projects_content(&self) -> Vec<Line<'static>> {
vec![Line::from("WIP")] vec![Line::from("WIP")]
@ -193,12 +177,12 @@ impl Content {
/// Generate the content for the "Blog" tab /// Generate the content for the "Blog" tab
#[cfg(feature = "blog")] #[cfg(feature = "blog")]
pub async fn blog_content(&self) -> Result<Vec<Post>> { pub async fn blog_content(&self) -> Result<Vec<String>> {
Ok(crate::atproto::blog::get_all_posts() Ok(crate::atproto::blog::get_all_posts()
.await? .await?
.iter() .iter()
.map(|post| Arc::new(post.clone())) .map(|post| post.title.clone().unwrap_or("<unknown>".to_string()))
.collect()) .collect::<Vec<String>>())
} }
} }
@ -228,12 +212,15 @@ impl Component for Content {
0 => self.about_content(area)?, 0 => self.about_content(area)?,
1 => self.projects_content(), 1 => self.projects_content(),
/* Blog tab handled in `App::render` */ /* Blog tab handled in `App::render` */
_ => vec![], _ => vec![],
}; };
// Create the border lines // Create the border lines
let mut border_top = Line::default(); let mut border_top = Line::default();
border_top.spans.push(Span::styled("", Style::default().fg(Color::DarkGray))); border_top
.spans
.push(Span::styled("", Style::default().fg(Color::DarkGray)));
let devcomp_width = 13; let devcomp_width = 13;
border_top.spans.push(Span::styled( border_top.spans.push(Span::styled(
@ -246,7 +233,12 @@ impl Component for Content {
for (i, &tab) in tabs.iter().enumerate() { for (i, &tab) in tabs.iter().enumerate() {
let (char, style) = if i == self.selected_tab.load(Ordering::Relaxed) { let (char, style) = if i == self.selected_tab.load(Ordering::Relaxed) {
("", Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)) (
"",
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
)
} else { } else {
("", Style::default().fg(Color::DarkGray)) ("", Style::default().fg(Color::DarkGray))
}; };
@ -255,7 +247,9 @@ impl Component for Content {
border_top.spans.push(Span::styled("", default_style)); border_top.spans.push(Span::styled("", default_style));
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(char.repeat(tab.len()), style));
border_top.spans.push(Span::styled("", default_style)); border_top.spans.push(Span::styled("", default_style));
border_top.spans.push(Span::styled("", default_style)); border_top.spans.push(Span::styled("", default_style));
@ -267,7 +261,9 @@ impl Component for Content {
Style::default().fg(Color::DarkGray), Style::default().fg(Color::DarkGray),
)); ));
border_top.spans.push(Span::styled("", Style::default().fg(Color::DarkGray))); border_top
.spans
.push(Span::styled("", Style::default().fg(Color::DarkGray)));
let border_bottom = Line::from(Span::styled( let border_bottom = Line::from(Span::styled(
"".to_owned() + &"".repeat(area.width as usize - 2) + "", "".to_owned() + &"".repeat(area.width as usize - 2) + "",
@ -295,23 +291,43 @@ impl Component for Content {
// Render the borders // Render the borders
frame.render_widget( frame.render_widget(
Paragraph::new(border_top), Paragraph::new(border_top),
Rect { x: area.x, y: area.y, width: area.width, height: 1 }, Rect {
x: area.x,
y: area.y,
width: area.width,
height: 1,
},
); );
frame.render_widget( frame.render_widget(
Paragraph::new(border_bottom), Paragraph::new(border_bottom),
Rect { x: area.x, y: area.y + area.height - 1, width: area.width, height: 1 }, Rect {
x: area.x,
y: area.y + area.height - 1,
width: area.width,
height: 1,
},
); );
for i in 1..area.height - 1 { for i in 1..area.height - 1 {
frame.render_widget( frame.render_widget(
Paragraph::new(Line::from(border_left.clone())), Paragraph::new(Line::from(border_left.clone())),
Rect { x: area.x, y: area.y + i, width: 1, height: 1 }, Rect {
x: area.x,
y: area.y + i,
width: 1,
height: 1,
},
); );
frame.render_widget( frame.render_widget(
Paragraph::new(Line::from(border_right.clone())), Paragraph::new(Line::from(border_right.clone())),
Rect { x: area.x + area.width - 1, y: area.y + i, width: 1, height: 1 }, Rect {
x: area.x + area.width - 1,
y: area.y + i,
width: 1,
height: 1,
},
); );
} }

View file

@ -1,38 +1,41 @@
use chrono::DateTime;
use color_eyre::eyre::Result; use color_eyre::eyre::Result;
use ratatui::prelude::*; use ratatui::{
use ratatui::widgets::*; style::{Color, Style},
widgets::{List, ListState},
};
use tokio::sync::mpsc::UnboundedSender; use tokio::sync::mpsc::UnboundedSender;
use crate::action::Action; use crate::{action::Action, components::Component, config::Config};
use crate::components::{Component, Post};
use crate::config::Config;
#[derive(Debug)] #[derive(Default)]
pub struct SelectionList<T> { pub struct SelectionList {
command_tx: Option<UnboundedSender<Action>>,
config: Config, config: Config,
pub(super) options: Vec<T>, options: List<'static>,
pub(super) list_state: ListState, list_state: ListState,
action_tx: Option<UnboundedSender<Action>>,
} }
impl<T> SelectionList<T> { impl SelectionList {
pub fn new(options: Vec<T>) -> Self { pub fn new(options: Vec<String>) -> Self {
let mut list_state = ListState::default(); let mut list_state = ListState::default();
list_state.select_first(); list_state.select_first();
Self { config: Config::default(), options, list_state, action_tx: None } Self {
options: List::new(options).highlight_style(Style::default().fg(Color::Yellow)),
list_state,
..Default::default()
}
} }
} }
impl Component for SelectionList<Post> { impl Component for SelectionList {
fn register_config_handler(&mut self, config: Config) -> Result<()> { fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> Result<()> {
self.config = config; self.command_tx = Some(tx);
Ok(()) Ok(())
} }
fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> Result<()> { fn register_config_handler(&mut self, config: Config) -> Result<()> {
self.action_tx = Some(tx); self.config = config;
Ok(()) Ok(())
} }
@ -40,88 +43,16 @@ impl Component for SelectionList<Post> {
match action { match action {
Action::Tick => {} Action::Tick => {}
Action::Render => {} Action::Render => {}
Action::Continue(None) => {
if let Some(tx) = &self.action_tx {
tx.send(Action::Continue(self.list_state.selected()))?;
}
}
Action::SelectNext => self.list_state.select_next(), Action::SelectNext => self.list_state.select_next(),
Action::SelectPrev => self.list_state.select_previous(), Action::SelectPrev => self.list_state.select_previous(),
_ => {} _ => {}
}; };
Ok(Some(action)) Ok(None)
} }
fn draw( fn draw(&mut self, frame: &mut ratatui::Frame, area: ratatui::prelude::Rect) -> Result<()> {
&mut self, frame.render_stateful_widget(self.options.clone(), area, &mut self.list_state);
frame: &mut ratatui::Frame,
area: ratatui::prelude::Rect,
) -> Result<()> {
let items = self.options.iter().enumerate().map(|(i, post)| {
let bold_style = Style::default().add_modifier(Modifier::BOLD);
let accent_style = bold_style.fg(Color::LightMagenta);
let post_creation_date = post
.created_at
.as_ref()
.map(|dt| DateTime::parse_from_rfc3339(dt.as_str()))
.and_then(Result::ok)
.map_or(DateTime::UNIX_EPOCH.date_naive().to_string(), |dt| {
dt.date_naive().to_string()
});
let arrow_or_pad =
if self.list_state.selected().is_some_and(|selection| selection == i) {
"".to_string()
} else {
format!("{:>2}", " ")
};
let padded_date = format!("{:>10}", post_creation_date);
let title_spans = vec![
Span::styled(arrow_or_pad, accent_style),
Span::raw(" "),
Span::styled(padded_date, bold_style),
Span::styled("", accent_style),
Span::styled(
post.title.clone().unwrap_or("[object Object]".to_string()), // LMAOOO
accent_style,
),
];
let mut list_content = vec![Line::from(title_spans)];
let line_format = [
Span::raw(format!("{:>14}", " ")),
Span::styled("", Style::default().add_modifier(Modifier::DIM)),
];
let subtitle_span = Span::raw(
[
" ",
post.subtitle
.as_ref()
.unwrap_or(&super::truncate(post.content.as_ref(), 40)),
]
.concat(),
);
list_content.push(Line::from([line_format.as_slice(), &[subtitle_span]].concat()));
list_content.push(Line::from([line_format.as_slice(), &[Span::raw("")]].concat()));
ListItem::new(list_content)
});
frame.render_stateful_widget(
List::new(items)
.block(Block::default().borders(Borders::NONE))
.highlight_style(Style::default())
.highlight_symbol(""),
area,
&mut self.list_state,
);
Ok(()) Ok(())
} }
} }

View file

@ -1,14 +1,11 @@
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{atomic::{AtomicUsize, Ordering}, Arc};
use std::sync::Arc;
use color_eyre::Result; use color_eyre::Result;
use ratatui::prelude::*; use ratatui::{prelude::*, widgets::*};
use ratatui::widgets::*;
use tokio::sync::mpsc::UnboundedSender; use tokio::sync::mpsc::UnboundedSender;
use super::Component; use super::Component;
use crate::action::Action; use crate::{action::Action, config::Config};
use crate::config::Config;
#[derive(Default)] #[derive(Default)]
pub struct Tabs { pub struct Tabs {
@ -20,7 +17,11 @@ pub struct Tabs {
impl Tabs { impl Tabs {
pub fn new(tabs: Vec<&'static str>, selected_tab: Arc<AtomicUsize>) -> Self { pub fn new(tabs: Vec<&'static str>, selected_tab: Arc<AtomicUsize>) -> Self {
Self { tabs, selected_tab, ..Default::default() } Self {
tabs,
selected_tab,
..Default::default()
}
} }
pub fn next(&mut self) { pub fn next(&mut self) {
@ -59,7 +60,7 @@ impl Component for Tabs {
Action::PrevTab => self.previous(), Action::PrevTab => self.previous(),
_ => {} _ => {}
}; };
Ok(None) Ok(None)
} }
@ -68,7 +69,9 @@ impl Component for Tabs {
for (i, &tab) in self.tabs.iter().enumerate() { for (i, &tab) in self.tabs.iter().enumerate() {
let style = if self.selected_tab.load(Ordering::Relaxed) == i { let style = if self.selected_tab.load(Ordering::Relaxed) == i {
Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD) Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD)
} else { } else {
Style::default().fg(Color::White) Style::default().fg(Color::White)
}; };
@ -78,19 +81,28 @@ impl Component for Tabs {
Style::default().fg(Color::DarkGray), Style::default().fg(Color::DarkGray),
)); ));
tab_lines[1].spans.push(Span::styled("", Style::default().fg(Color::DarkGray))); tab_lines[1]
tab_lines[1].spans.push(Span::styled(format!(" {} ", tab), style)); .spans
tab_lines[1].spans.push(Span::styled("", Style::default().fg(Color::DarkGray))); .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 = let tabs_widget = Paragraph::new(tab_lines).block(Block::default().borders(Borders::NONE));
Paragraph::new(tab_lines).block(Block::default().borders(Borders::NONE));
frame.render_widget( frame.render_widget(
tabs_widget, tabs_widget,
Rect { x: area.x, y: area.y, width: area.width, height: 2 }, Rect {
x: area.x,
y: area.y,
width: area.width,
height: 2,
},
); );
Ok(()) Ok(())
} }}
}

View file

@ -1,8 +1,6 @@
#![allow(dead_code)] // Remove this once you start using the code #![allow(dead_code)] // Remove this once you start using the code
use std::collections::HashMap; use std::{collections::HashMap, env, path::PathBuf};
use std::env;
use std::path::PathBuf;
use color_eyre::Result; use color_eyre::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
@ -10,12 +8,10 @@ use derive_deref::{Deref, DerefMut};
use directories::ProjectDirs; use directories::ProjectDirs;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use ratatui::style::{Color, Modifier, Style}; use ratatui::style::{Color, Modifier, Style};
use serde::de::Deserializer; use serde::{de::Deserializer, Deserialize};
use serde::Deserialize;
use tracing::error; use tracing::error;
use crate::action::Action; use crate::{action::Action, app::Mode};
use crate::app::Mode;
const CONFIG: &str = include_str!("../.config/config.json5"); const CONFIG: &str = include_str!("../.config/config.json5");
@ -40,9 +36,13 @@ pub struct Config {
lazy_static! { lazy_static! {
pub static ref PROJECT_NAME: String = env!("CARGO_CRATE_NAME").to_uppercase().to_string(); pub static ref PROJECT_NAME: String = env!("CARGO_CRATE_NAME").to_uppercase().to_string();
pub static ref DATA_FOLDER: Option<PathBuf> = pub static ref DATA_FOLDER: Option<PathBuf> =
env::var(format!("{}_DATA", PROJECT_NAME.clone())).ok().map(PathBuf::from); env::var(format!("{}_DATA", PROJECT_NAME.clone()))
.ok()
.map(PathBuf::from);
pub static ref CONFIG_FOLDER: Option<PathBuf> = pub static ref CONFIG_FOLDER: Option<PathBuf> =
env::var(format!("{}_CONFIG", PROJECT_NAME.clone())).ok().map(PathBuf::from); env::var(format!("{}_CONFIG", PROJECT_NAME.clone()))
.ok()
.map(PathBuf::from);
} }
impl Config { impl Config {
@ -63,8 +63,9 @@ impl Config {
]; ];
let mut found_config = false; let mut found_config = false;
for (file, format) in &config_files { for (file, format) in &config_files {
let source = let source = config::File::from(config_dir.join(file))
config::File::from(config_dir.join(file)).format(*format).required(false); .format(*format)
.required(false);
builder = builder.add_source(source); builder = builder.add_source(source);
if config_dir.join(file).exists() { if config_dir.join(file).exists() {
found_config = true found_config = true
@ -79,7 +80,9 @@ impl Config {
for (mode, default_bindings) in default_config.keybindings.iter() { for (mode, default_bindings) in default_config.keybindings.iter() {
let user_bindings = cfg.keybindings.entry(*mode).or_default(); let user_bindings = cfg.keybindings.entry(*mode).or_default();
for (key, cmd) in default_bindings.iter() { for (key, cmd) in default_bindings.iter() {
user_bindings.entry(key.clone()).or_insert_with(|| cmd.clone()); user_bindings
.entry(key.clone())
.or_insert_with(|| cmd.clone());
} }
} }
for (mode, default_styles) in default_config.styles.iter() { for (mode, default_styles) in default_config.styles.iter() {
@ -125,7 +128,8 @@ pub struct KeyBindings(pub HashMap<Mode, HashMap<Vec<KeyEvent>, Action>>);
impl<'de> Deserialize<'de> for KeyBindings { impl<'de> Deserialize<'de> for KeyBindings {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where where
D: Deserializer<'de>, { D: Deserializer<'de>,
{
let parsed_map = HashMap::<Mode, HashMap<String, Action>>::deserialize(deserializer)?; let parsed_map = HashMap::<Mode, HashMap<String, Action>>::deserialize(deserializer)?;
let keybindings = parsed_map let keybindings = parsed_map
@ -320,7 +324,8 @@ pub struct Styles(pub HashMap<Mode, HashMap<String, Style>>);
impl<'de> Deserialize<'de> for Styles { impl<'de> Deserialize<'de> for Styles {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where where
D: Deserializer<'de>, { D: Deserializer<'de>,
{
let parsed_map = HashMap::<Mode, HashMap<String, String>>::deserialize(deserializer)?; let parsed_map = HashMap::<Mode, HashMap<String, String>>::deserialize(deserializer)?;
let styles = parsed_map let styles = parsed_map
@ -382,13 +387,22 @@ fn parse_color(s: &str) -> Option<Color> {
let s = s.trim_end(); let s = s.trim_end();
if s.contains("bright color") { if s.contains("bright color") {
let s = s.trim_start_matches("bright "); let s = s.trim_start_matches("bright ");
let c = s.trim_start_matches("color").parse::<u8>().unwrap_or_default(); let c = s
.trim_start_matches("color")
.parse::<u8>()
.unwrap_or_default();
Some(Color::Indexed(c.wrapping_shl(8))) Some(Color::Indexed(c.wrapping_shl(8)))
} else if s.contains("color") { } else if s.contains("color") {
let c = s.trim_start_matches("color").parse::<u8>().unwrap_or_default(); let c = s
.trim_start_matches("color")
.parse::<u8>()
.unwrap_or_default();
Some(Color::Indexed(c)) Some(Color::Indexed(c))
} else if s.contains("gray") { } else if s.contains("gray") {
let c = 232 + s.trim_start_matches("gray").parse::<u8>().unwrap_or_default(); let c = 232
+ s.trim_start_matches("gray")
.parse::<u8>()
.unwrap_or_default();
Some(Color::Indexed(c)) Some(Color::Indexed(c))
} else if s.contains("rgb") { } else if s.contains("rgb") {
let red = (s.as_bytes()[3] as char).to_digit(10).unwrap_or_default() as u8; let red = (s.as_bytes()[3] as char).to_digit(10).unwrap_or_default() as u8;
@ -540,7 +554,10 @@ mod tests {
fn test_multiple_modifiers() { fn test_multiple_modifiers() {
assert_eq!( assert_eq!(
parse_key_event("ctrl-alt-a").unwrap(), parse_key_event("ctrl-alt-a").unwrap(),
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL | KeyModifiers::ALT) KeyEvent::new(
KeyCode::Char('a'),
KeyModifiers::CONTROL | KeyModifiers::ALT
)
); );
assert_eq!( assert_eq!(

View file

@ -15,6 +15,12 @@ pub fn init() -> Result<()> {
.into_hooks(); .into_hooks();
eyre_hook.install()?; eyre_hook.install()?;
std::panic::set_hook(Box::new(move |panic_info| { std::panic::set_hook(Box::new(move |panic_info| {
// if let Ok(mut t) = crate::tui::Tui::new() {
// if let Err(r) = t.exit() {
// error!("Unable to exit Terminal: {:?}", r);
// }
// }
#[cfg(not(debug_assertions))] #[cfg(not(debug_assertions))]
{ {
use human_panic::{handle_dump, metadata, print_msg}; use human_panic::{handle_dump, metadata, print_msg};
@ -36,11 +42,10 @@ pub fn init() -> Result<()> {
.lineno_suffix(true) .lineno_suffix(true)
.verbosity(better_panic::Verbosity::Full) .verbosity(better_panic::Verbosity::Full)
.create_panic_handler()(panic_info); .create_panic_handler()(panic_info);
std::process::exit(libc::EXIT_FAILURE);
} }
}));
std::process::exit(libc::EXIT_FAILURE);
}));
Ok(()) Ok(())
} }

View file

@ -53,14 +53,14 @@ impl KeyCodeExt for KeyCode {
// Caps/Num/Scroll Lock // Caps/Num/Scroll Lock
20 => KeyCode::CapsLock, 20 => KeyCode::CapsLock,
144 => KeyCode::NumLock, 144 => KeyCode::NumLock,
145 => KeyCode::ScrollLock, 145 => KeyCode::ScrollLock,
// Pause/Break // Pause/Break
19 => KeyCode::Pause, 19 => KeyCode::Pause,
// Anything else // Anything else
_ => KeyCode::Null, 0 | _ => KeyCode::Null,
} }
} }
#[rustfmt::skip] #[rustfmt::skip]
@ -95,7 +95,7 @@ impl KeyCodeExt for KeyCode {
[single] => *single, [single] => *single,
_ => KeyCode::Null, _ => KeyCode::Null,
} }
} }
fn into_key_event(self) -> KeyEvent { fn into_key_event(self) -> KeyEvent {
match self { match self {
@ -107,7 +107,6 @@ impl KeyCodeExt for KeyCode {
} }
} }
#[rustfmt::skip]
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -170,4 +169,4 @@ mod tests {
let key_event = <KeyCode as KeyCodeExt>::into_key_event(key_code); let key_event = <KeyCode as KeyCodeExt>::into_key_event(key_code);
assert_eq!(key_event, KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())); assert_eq!(key_event, KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()));
} }
} }

View file

@ -1,54 +0,0 @@
use std::io;
use std::net::SocketAddr;
use actix_web::middleware::Logger;
use actix_web::{web, App, HttpResponse, HttpServer, Responder};
use rust_embed::Embed;
use tracing::instrument;
macro_rules! embedded_route {
($path:expr) => {
match WebLandingServer::get($path) {
Some(content) => HttpResponse::Ok()
.content_type(mime_guess::from_path($path).first_or_octet_stream().as_ref())
.body(content.data.into_owned()),
None => HttpResponse::NotFound().body("404 Not Found"),
}
};
($name:ident,$pat:expr,$path:literal) => {
#[actix_web::get($pat)]
async fn $name() -> impl Responder {
embedded_route!($path)
}
};
($name:ident,$pat:expr) => {
#[actix_web::get($pat)]
async fn $name(path: web::Path<String>) -> impl Responder {
embedded_route!(path.as_str())
}
};
}
#[derive(Embed)]
#[folder = "www/build"]
pub struct WebLandingServer;
impl WebLandingServer {
#[instrument(level = "trace")]
pub async fn start(addr: SocketAddr) -> io::Result<()> {
tracing::info!("Web server listening on {}", addr);
HttpServer::new(|| {
// TODO: register a default service for a nicer 404 page
App::new().service(index).service(favicon).service(dist).wrap(Logger::default())
})
.bind(addr)?
.run()
.await
}
}
embedded_route!(index, "/", "index.html");
embedded_route!(favicon, "/favicon.png", "favicon.png");
embedded_route!(dist, "/{path:.*}");

View file

@ -1,11 +1,8 @@
use std::io::stderr; use std::io::stderr;
use color_eyre::eyre::eyre; use color_eyre::{eyre::eyre, Result};
use color_eyre::Result;
use tracing_error::ErrorLayer; use tracing_error::ErrorLayer;
use tracing_subscriber::prelude::*; use tracing_subscriber::{fmt, prelude::*, EnvFilter, util::TryInitError};
use tracing_subscriber::util::TryInitError;
use tracing_subscriber::{fmt, EnvFilter};
use crate::config; use crate::config;
@ -30,18 +27,14 @@ pub fn init() -> Result<()> {
// //
// Stage 1: Construct base filter // Stage 1: Construct base filter
let env_filter = EnvFilter::builder().with_default_directive(if cfg!(debug_assertions) { let env_filter = EnvFilter::builder().with_default_directive(tracing::Level::INFO.into());
tracing::Level::DEBUG.into()
} else {
tracing::Level::INFO.into()
});
// Stage 2: Attempt to read from {RUST|CRATE_NAME}_LOG env var or ignore // Stage 2: Attempt to read from {RUST|CRATE_NAME}_LOG env var or ignore
let env_filter = env_filter let env_filter = env_filter.try_from_env().unwrap_or_else(|_| {
.try_from_env() env_filter
.unwrap_or_else(|_| env_filter.with_env_var(LOG_ENV.to_string()).from_env_lossy()) .with_env_var(LOG_ENV.to_string())
.add_directive("russh::cipher=info".parse().unwrap()) .from_env_lossy()
.add_directive("tui_markdown=info".parse().unwrap()); });
// Stage 3: Enable directives to reduce verbosity for release mode builds // Stage 3: Enable directives to reduce verbosity for release mode builds
#[cfg(not(debug_assertions))] #[cfg(not(debug_assertions))]
@ -59,7 +52,6 @@ pub fn init() -> Result<()> {
// Build the subscriber and apply it // Build the subscriber and apply it
tracing_subscriber::registry() tracing_subscriber::registry()
.with(env_filter) .with(env_filter)
.with(ErrorLayer::default())
.with( .with(
// Logging to file // Logging to file
fmt::layer() fmt::layer()
@ -74,19 +66,15 @@ pub fn init() -> Result<()> {
let layer = fmt::layer() let layer = fmt::layer()
.with_writer(stderr) .with_writer(stderr)
.with_timer(tracing_subscriber::fmt::time()) .with_timer(tracing_subscriber::fmt::time())
.with_thread_ids(true)
.with_ansi(true); .with_ansi(true);
// Enable compact mode for release logs // Enable compact mode for release logs
#[cfg(not(debug_assertions))] #[cfg(not(debug_assertions))]
let layer = layer return layer.compact();
.compact() #[cfg(debug_assertions)]
.without_time()
.with_span_events(tracing_subscriber::fmt::format::FmtSpan::NONE)
.with_target(false)
.with_thread_ids(false);
layer layer
}) })
.with(ErrorLayer::default())
.try_init() .try_init()
.map_err(|err: TryInitError| eyre!(err)) .map_err(|err: TryInitError| eyre!(err))
} }

View file

@ -1,22 +1,22 @@
use std::net::SocketAddr; use std::{net::SocketAddr, sync::Arc};
use clap::Parser as _; use clap::Parser as _;
use cli::Cli; use cli::Cli;
use color_eyre::eyre::eyre; use color_eyre::{eyre::eyre, Result};
use color_eyre::Result;
use lazy_static::lazy_static; use lazy_static::lazy_static;
use russh::keys::PrivateKey; use russh::{
use russh::server::Config; keys::PrivateKey,
use russh::MethodSet; server::{Config, Server},
MethodSet,
};
use ssh::SshServer; use ssh::SshServer;
use tokio::net::TcpListener;
#[cfg(feature = "blog")] #[cfg(feature = "blog")]
pub(crate) use atproto::com; pub(crate) use atproto::com;
#[cfg(feature = "blog")] #[cfg(feature = "blog")]
pub(crate) use atrium_api::*; pub(crate) use atrium_api::*;
use crate::landing::WebLandingServer;
mod action; mod action;
mod app; mod app;
#[cfg(feature = "blog")] #[cfg(feature = "blog")]
@ -26,7 +26,6 @@ mod components;
mod config; mod config;
mod errors; mod errors;
mod keycode; mod keycode;
mod landing;
mod logging; mod logging;
mod ssh; mod ssh;
mod tui; mod tui;
@ -36,12 +35,24 @@ const SSH_KEYS: &[&[u8]] = &[
include_bytes!(concat!(env!("OUT_DIR"), "/ecdsa.pem")), include_bytes!(concat!(env!("OUT_DIR"), "/ecdsa.pem")),
include_bytes!(concat!(env!("OUT_DIR"), "/ed25519.pem")), include_bytes!(concat!(env!("OUT_DIR"), "/ed25519.pem")),
]; ];
#[rustfmt::skip]
lazy_static! { lazy_static! {
pub(crate) static ref OPTIONS: Cli = Cli::parse(); pub(crate) static ref OPTIONS: Cli = Cli::parse();
pub(crate) static ref SSH_SOCKET_ADDR: Option<SocketAddr> = Some(SocketAddr::from((host_ip().ok()?, OPTIONS.ssh_port))); pub(crate) static ref SOCKET_ADDR: Option<SocketAddr> = Some(SocketAddr::from((
pub(crate) static ref WEB_SERVER_ADDR: Option<SocketAddr> = Some(SocketAddr::from((host_ip().ok()?, OPTIONS.web_port))); // Convert the hostname IP to a fixed size array of [u8; 4]
TryInto::<[u8; 4]>::try_into(
OPTIONS
.host
.splitn(4, ".")
.map(|octet_str| u8::from_str_radix(octet_str, 10)
.map_err(|_| eyre!("Octet component out of range (expected u8)")))
.collect::<Result<Vec<u8>>>()
.ok()?
)
.ok()?,
// The port to listen on
OPTIONS.port
)));
} }
#[tokio::main] #[tokio::main]
@ -50,41 +61,24 @@ async fn main() -> Result<()> {
crate::logging::init()?; crate::logging::init()?;
let _ = *OPTIONS; // force clap to run by evaluating it let _ = *OPTIONS; // force clap to run by evaluating it
let ssh_socket_addr = SSH_SOCKET_ADDR.ok_or(eyre!("Invalid host IP provided"))?; let socket_addr = SOCKET_ADDR.ok_or(eyre!("Invalid host IP provided"))?;
let web_server_addr = WEB_SERVER_ADDR.ok_or(eyre!("Invalid host IP provided"))?; let config = ssh_config();
tokio::select! { tracing::info!("Attempting to listen on {}", socket_addr);
ssh_res = SshServer::start(ssh_socket_addr, ssh_config()) => ssh_res, SshServer::default()
web_res = WebLandingServer::start(web_server_addr) => web_res.map_err(|err| eyre!(err)), .run_on_socket(Arc::new(config), &TcpListener::bind(socket_addr).await?)
} .await
} .map_err(|err| eyre!(err))
/// Converts the supplied hostname IP via CLI to a fixed size array of `[u8; 4]`, defaults to `127.0.0.1`
pub fn host_ip() -> Result<[u8; 4]> {
TryInto::<[u8; 4]>::try_into(
OPTIONS
.host
.splitn(4, ".")
.map(|octet_str| {
octet_str
.parse::<u8>()
.map_err(|_| eyre!("Octet component out of range (expected u8)"))
})
.collect::<Result<Vec<u8>>>()?,
)
.map_err(|_| eyre!("Invalid host IP provided"))
} }
fn ssh_config() -> Config { fn ssh_config() -> Config {
let conf = Config { let mut conf = Config::default();
methods: MethodSet::NONE, conf.methods = MethodSet::NONE;
keys: SSH_KEYS conf.keys = SSH_KEYS
.to_vec() .to_vec()
.iter() .iter()
.filter_map(|pem| PrivateKey::from_openssh(pem).ok()) .filter_map(|pem| PrivateKey::from_openssh(pem).ok())
.collect(), .collect();
..Default::default()
};
tracing::trace!("SSH config: {:#?}", conf); tracing::trace!("SSH config: {:#?}", conf);
conf conf

View file

@ -1,21 +1,22 @@
use std::io::Write; use std::{io::Write, net::SocketAddr, sync::Arc};
use std::net::SocketAddr;
use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use color_eyre::eyre::{self, eyre}; use color_eyre::eyre::{self, eyre};
use russh::server::{Auth, Config, Handle, Handler, Msg, Server, Session}; use russh::{
use russh::{Channel, ChannelId, CryptoVec, Pty}; server::{Auth, Handle, Handler, Msg, Server, Session},
use tokio::net::TcpListener; Channel, ChannelId, CryptoVec, Pty,
use tokio::runtime::Handle as TokioHandle; };
use tokio::sync::{mpsc, oneshot, Mutex, RwLock}; use tokio::{
runtime::Handle as TokioHandle,
sync::{mpsc, oneshot, Mutex, RwLock},
};
use tracing::instrument; use tracing::instrument;
use crate::app::App; use crate::{
use crate::tui::backend::SshBackend; app::App,
use crate::tui::terminal::{TerminalInfo, TerminalKind, UnsupportedReason}; tui::{backend::SshBackend, Terminal, Tui},
use crate::tui::{Terminal, Tui}; OPTIONS,
use crate::OPTIONS; };
#[derive(Debug)] #[derive(Debug)]
pub struct TermWriter { pub struct TermWriter {
@ -29,7 +30,11 @@ impl TermWriter {
#[instrument(skip(session, channel), level = "trace", fields(channel_id = %channel.id()))] #[instrument(skip(session, channel), level = "trace", fields(channel_id = %channel.id()))]
fn new(session: Handle, channel: Channel<Msg>) -> Self { fn new(session: Handle, channel: Channel<Msg>) -> Self {
tracing::trace!("Acquiring new SSH writer"); tracing::trace!("Acquiring new SSH writer");
Self { session, channel, inner: CryptoVec::new() } Self {
session,
channel,
inner: CryptoVec::new(),
}
} }
fn flush_inner(&mut self) -> std::io::Result<()> { fn flush_inner(&mut self) -> std::io::Result<()> {
@ -39,11 +44,9 @@ impl TermWriter {
.data(self.channel.id(), self.inner.clone()) .data(self.channel.id(), self.inner.clone())
.await .await
.map_err(|err| { .map_err(|err| {
std::io::Error::other(String::from_iter( std::io::Error::other(String::from_iter(err.iter().map(|item| *item as char)))
err.iter().map(|item| *item as char),
))
}) })
.map(|_| self.inner.clear()) .and_then(|()| Ok(self.inner.clear()))
}) })
} }
} }
@ -51,7 +54,10 @@ impl TermWriter {
impl Write for TermWriter { impl Write for TermWriter {
#[instrument(skip(self, buf), level = "debug")] #[instrument(skip(self, buf), level = "debug")]
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> { fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
tracing::trace!("Writing {} bytes into SSH terminal writer buffer", buf.len()); tracing::trace!(
"Writing {} bytes into SSH terminal writer buffer",
buf.len()
);
self.inner.extend(buf); self.inner.extend(buf);
Ok(buf.len()) Ok(buf.len())
} }
@ -63,9 +69,7 @@ impl Write for TermWriter {
} }
} }
#[allow(clippy::type_complexity)]
pub struct SshSession { pub struct SshSession {
terminal_info: Arc<RwLock<TerminalInfo>>,
app: Option<Arc<Mutex<App>>>, app: Option<Arc<Mutex<App>>>,
keystroke_tx: mpsc::UnboundedSender<Vec<u8>>, keystroke_tx: mpsc::UnboundedSender<Vec<u8>>,
resize_tx: mpsc::UnboundedSender<(u16, u16)>, resize_tx: mpsc::UnboundedSender<(u16, u16)>,
@ -80,18 +84,13 @@ impl SshSession {
let (resize_tx, resize_rx) = mpsc::unbounded_channel(); let (resize_tx, resize_rx) = mpsc::unbounded_channel();
let (init_dims_tx, init_dims_rx) = oneshot::channel(); let (init_dims_tx, init_dims_rx) = oneshot::channel();
let term_info = Arc::new(RwLock::new(TerminalInfo::default()));
Self { Self {
terminal_info: Arc::clone(&term_info),
app: App::new( app: App::new(
term_info,
OPTIONS.tick_rate, OPTIONS.tick_rate,
OPTIONS.frame_rate, OPTIONS.frame_rate,
keystroke_rx, keystroke_rx,
resize_rx, resize_rx,
) )
.inspect_err(|err| tracing::error!("Failed to create app: {err}"))
.ok() .ok()
.map(|app| Arc::new(Mutex::new(app))), .map(|app| Arc::new(Mutex::new(app))),
tui: Arc::new(RwLock::new(None)), tui: Arc::new(RwLock::new(None)),
@ -104,13 +103,22 @@ impl SshSession {
async fn run_app( async fn run_app(
app: Arc<Mutex<App>>, app: Arc<Mutex<App>>,
term: Arc<Mutex<Terminal>>, writer: Arc<Mutex<Terminal>>,
tui: Arc<RwLock<Option<Tui>>>, tui: Arc<RwLock<Option<Tui>>>,
session: &Handle, session: &Handle,
channel_id: ChannelId, channel_id: ChannelId,
) -> eyre::Result<()> { ) -> eyre::Result<()> {
app.lock_owned().await.run(term, tui).await?; // let mut tui_inner = Tui::new(writer.clone())?;
session.close(channel_id).await.map_err(|_| eyre!("failed to close session"))?; // 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)
.await
.map_err(|_| eyre!("failed to close session"))?;
session session
.exit_status_request(channel_id, 0) .exit_status_request(channel_id, 0)
.await .await
@ -143,24 +151,23 @@ impl Handler for SshSession {
tracing::info!("Serving app to open session"); tracing::info!("Serving app to open session");
tokio::task::spawn(async move { tokio::task::spawn(async move {
let result = let result: Result<(), Box<dyn std::error::Error + Send + Sync>> = (|| async {
async || -> Result<(), Box<dyn std::error::Error + Send + Sync>> { let ((term_width, term_height), (pixel_width, pixel_height)) = rx.await?;
let ((term_width, term_height), (pixel_width, pixel_height)) = let writer = Arc::new(Mutex::new(Terminal::new(SshBackend::new(
rx.await?; TermWriter::new(session_handle.clone(), channel),
let writer = Arc::new(Mutex::new(Terminal::new(SshBackend::new( term_width,
TermWriter::new(session_handle.clone(), channel), term_height,
term_width, pixel_width,
term_height, pixel_height,
pixel_width, ))?));
pixel_height,
))?));
Self::run_app(inner_app, writer, tui, &session_handle, channel_id) Self::run_app(inner_app, writer, tui, &session_handle, channel_id).await?;
.await?; Ok(())
Ok(()) })(
}; )
.await;
match result().await { match result {
Ok(()) => tracing::info!("Session exited successfully"), Ok(()) => tracing::info!("Session exited successfully"),
Err(err) => { Err(err) => {
tracing::error!("Session errored: {err}"); tracing::error!("Session errored: {err}");
@ -175,31 +182,6 @@ impl Handler for SshSession {
Err(eyre!("Failed to initialize App for session")) Err(eyre!("Failed to initialize App for session"))
} }
#[instrument(level = "debug", skip(self, _session), fields(channel_id = %_channel_id))]
async fn env_request(
&mut self,
_channel_id: ChannelId,
variable_name: &str,
variable_value: &str,
_session: &mut Session,
) -> Result<(), Self::Error> {
// FIXME: currently, terminals which don't set `$TERM_PROGRAM` just get stuck in the
// polling loop forever where we wait for the type to be probed, a workaround is to force
// set the variable to an empty string or something invalid:
//
// `TERM_PROGRAM="" ssh -o SendEnv=TERM_PROGRAM devcomp.xyz`
if variable_name == "TERM_PROGRAM" {
self.terminal_info
.write()
.await
.set_kind(TerminalKind::from_term_program(variable_value));
tracing::info!("Terminal program found: {:?}", self.terminal_info);
}
Ok(())
}
#[instrument(skip_all, fields(channel_id = %channel_id))] #[instrument(skip_all, fields(channel_id = %channel_id))]
async fn pty_request( async fn pty_request(
&mut self, &mut self,
@ -215,18 +197,6 @@ impl Handler for SshSession {
tracing::info!("PTY requested by terminal: {term}"); tracing::info!("PTY requested by terminal: {term}");
tracing::debug!("dims: {col_width} * {row_height}, pixel: {pix_width} * {pix_height}"); tracing::debug!("dims: {col_width} * {row_height}, pixel: {pix_width} * {pix_height}");
if pix_width != 0 && pix_height != 0 {
self.terminal_info.write().await.set_font_size((
(pix_width / col_width).try_into().or(Err(eyre!("Terminal too wide")))?,
(pix_height / row_height).try_into().or(Err(eyre!("Terminal too tall")))?,
));
} else {
self.terminal_info
.write()
.await
.set_kind(TerminalKind::Unsupported(UnsupportedReason::Unsized));
}
if !term.contains("xterm") { if !term.contains("xterm") {
session.channel_failure(channel_id)?; session.channel_failure(channel_id)?;
return Err(eyre!("Unsupported terminal type: {term}")); return Err(eyre!("Unsupported terminal type: {term}"));
@ -265,15 +235,12 @@ impl Handler for SshSession {
_: ChannelId, _: ChannelId,
col_width: u32, col_width: u32,
row_height: u32, row_height: u32,
pix_width: u32, _: u32,
pix_height: u32, _: u32,
_: &mut Session, _: &mut Session,
) -> Result<(), Self::Error> { ) -> Result<(), Self::Error> {
self.terminal_info.write().await.set_font_size(( // TODO: actually make it resize properly
(pix_width / col_width).try_into().or(Err(eyre!("Terminal too wide")))?, // That would involve first updating the Backend's size and then updating the rect via the event
(pix_height / row_height).try_into().or(Err(eyre!("Terminal too tall")))?,
));
self.resize_tx self.resize_tx
.send((col_width as u16, row_height as u16)) .send((col_width as u16, row_height as u16))
.map_err(|_| eyre!("Failed to send pty size specifications"))?; .map_err(|_| eyre!("Failed to send pty size specifications"))?;
@ -285,21 +252,13 @@ impl Handler for SshSession {
#[derive(Default)] #[derive(Default)]
pub struct SshServer; pub struct SshServer;
impl SshServer {
#[instrument(level = "trace")]
pub async fn start(addr: SocketAddr, config: Config) -> eyre::Result<()> {
let listener = TcpListener::bind(addr).await?;
Self.run_on_socket(Arc::new(config), &listener).await.map_err(|err| eyre!(err))
}
}
#[async_trait] #[async_trait]
impl Server for SshServer { impl Server for SshServer {
type Handler = SshSession; type Handler = SshSession;
#[instrument(skip(self))] #[instrument(skip(self))]
fn new_client(&mut self, peer_addr: Option<SocketAddr>) -> Self::Handler { fn new_client(&mut self, peer_addr: Option<SocketAddr>) -> Self::Handler {
tokio::task::block_in_place(SshSession::new) let session = tokio::task::block_in_place(|| SshSession::new());
session
} }
} }

View file

@ -1,8 +1,9 @@
use std::io; use std::{io, ops::{Deref, DerefMut}};
use std::ops::{Deref, DerefMut};
use ratatui::backend::{Backend, CrosstermBackend, WindowSize}; use ratatui::{
use ratatui::layout::Size; backend::{Backend, CrosstermBackend, WindowSize},
layout::Size,
};
use crate::ssh::TermWriter; use crate::ssh::TermWriter;
@ -14,30 +15,28 @@ pub struct SshBackend {
} }
impl SshBackend { impl SshBackend {
pub fn new( pub fn new(writer: TermWriter, init_width: u16, init_height: u16, init_pixel_width: u16, init_pixdel_height: u16) -> Self {
writer: TermWriter,
init_width: u16,
init_height: u16,
init_pixel_width: u16,
init_pixel_height: u16,
) -> Self {
let inner = CrosstermBackend::new(writer); let inner = CrosstermBackend::new(writer);
SshBackend { SshBackend {
inner, inner,
dims: (init_width, init_height), dims: (init_width, init_height),
pixel: (init_pixel_width, init_pixel_height), pixel: (init_pixel_width, init_pixdel_height),
} }
} }
} }
impl Backend for SshBackend { impl Backend for SshBackend {
fn size(&self) -> io::Result<Size> { fn size(&self) -> io::Result<Size> {
Ok(Size { width: self.dims.0, height: self.dims.1 }) Ok(Size {
width: self.dims.0,
height: self.dims.1,
})
} }
fn draw<'a, I>(&mut self, content: I) -> io::Result<()> fn draw<'a, I>(&mut self, content: I) -> io::Result<()>
where where
I: Iterator<Item = (u16, u16, &'a ratatui::buffer::Cell)>, { I: Iterator<Item = (u16, u16, &'a ratatui::buffer::Cell)>,
{
self.inner.draw(content) self.inner.draw(content)
} }
@ -88,4 +87,4 @@ impl DerefMut for SshBackend {
fn deref_mut(&mut self) -> &mut Self::Target { fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.inner &mut self.inner
} }
} }

View file

@ -1,29 +1,33 @@
#![allow(dead_code)] // TODO: Remove this once you start using the code #![allow(dead_code)] // TODO: Remove this once you start using the code
use std::sync::Arc; use std::{sync::Arc, time::Duration};
use std::time::Duration;
use backend::SshBackend; use backend::SshBackend;
use color_eyre::Result; use color_eyre::Result;
use crossterm::cursor; use crossterm::{
use crossterm::event::{ cursor,
DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, event::{
KeyEvent, MouseEvent, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
KeyEvent, MouseEvent,
},
terminal::{EnterAlternateScreen, LeaveAlternateScreen},
}; };
use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use status::TuiStatus; use status::TuiStatus;
use tokio::runtime::Handle; use tokio::{
use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; runtime::Handle,
use tokio::sync::{Mutex, RwLock}; sync::{
use tokio::task::{block_in_place, JoinHandle}; mpsc::{self, UnboundedReceiver, UnboundedSender},
use tokio::time::{interval, sleep, timeout}; Mutex, RwLock,
},
task::{block_in_place, JoinHandle},
time::{interval, sleep, timeout},
};
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use tracing::error; use tracing::error;
pub(crate) mod backend; pub(crate) mod backend;
pub(crate) mod status; pub(crate) mod status;
pub(crate) mod terminal;
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub enum Event { pub enum Event {
@ -122,7 +126,9 @@ impl Tui {
let mut render_interval = interval(Duration::from_secs_f64(1.0 / frame_rate)); let mut render_interval = interval(Duration::from_secs_f64(1.0 / frame_rate));
// if this fails, then it's likely a bug in the calling code // if this fails, then it's likely a bug in the calling code
event_tx.send(Event::Init).expect("failed to send init event"); event_tx
.send(Event::Init)
.expect("failed to send init event");
let suspension_status = Arc::clone(&status); let suspension_status = Arc::clone(&status);
loop { loop {
@ -151,7 +157,7 @@ impl Tui {
pub async fn stop(&self) -> Result<()> { pub async fn stop(&self) -> Result<()> {
self.cancel(); self.cancel();
let attempt_timeout = Duration::from_millis(50); let attempt_timeout = Duration::from_millis(50);
let abort_shutdown = async { let abort_shutdown = async {
while !self.task.is_finished() { while !self.task.is_finished() {
@ -159,10 +165,12 @@ impl Tui {
} }
}; };
if timeout(attempt_timeout, self.await_shutdown()).await.is_err() { if let Err(_) = timeout(attempt_timeout, self.await_shutdown()).await {
timeout(attempt_timeout, abort_shutdown).await.inspect_err(|_| { timeout(attempt_timeout, abort_shutdown)
error!("Failed to abort task in 100 milliseconds for unknown reason") .await
})?; .inspect_err(|_| {
error!("Failed to abort task in 100 milliseconds for unknown reason")
})?;
} }
Ok(()) Ok(())
@ -190,18 +198,21 @@ impl Tui {
pub async fn exit(&mut self) -> Result<()> { pub async fn exit(&mut self) -> Result<()> {
self.stop().await?; self.stop().await?;
// TODO: enable raw mode for pty // TODO: enable raw mode for pty
let mut term = self.terminal.try_lock()?; if true || crossterm::terminal::is_raw_mode_enabled()? {
term.flush()?; let mut term = self.terminal.try_lock()?;
term.flush()?;
if self.paste { if self.paste {
crossterm::execute!(term.backend_mut(), DisableBracketedPaste)?; crossterm::execute!(term.backend_mut(), DisableBracketedPaste)?;
}
if self.mouse {
crossterm::execute!(term.backend_mut(), DisableMouseCapture)?;
}
crossterm::execute!(term.backend_mut(), LeaveAlternateScreen, cursor::Show)?;
// crossterm::terminal::disable_raw_mode()?; // TODO: disable raw mode
} }
if self.mouse {
crossterm::execute!(term.backend_mut(), DisableMouseCapture)?;
}
crossterm::execute!(term.backend_mut(), LeaveAlternateScreen, cursor::Show)?;
Ok(()) Ok(())
} }
@ -247,7 +258,9 @@ impl Drop for Tui {
block_in_place(|| { block_in_place(|| {
let handle = Handle::current(); let handle = Handle::current();
let _ = handle.block_on(async { let _ = handle.block_on(async {
self.exit().await.inspect_err(|err| error!("Failed to exit Tui: {err}")) self.exit()
.await
.inspect_err(|err| error!("Failed to exit Tui: {err}"))
}); });
}) })
} }

View file

@ -1,6 +1,4 @@
use std::future::{Future, IntoFuture}; use std::{future::{Future, IntoFuture}, pin::Pin, sync::Arc};
use std::pin::Pin;
use std::sync::Arc;
use futures::future; use futures::future;
use tokio::sync::Mutex; use tokio::sync::Mutex;
@ -24,4 +22,4 @@ impl IntoFuture for TuiStatus {
Box::pin(future::ready(())) Box::pin(future::ready(()))
} }
} }

View file

@ -1,153 +0,0 @@
use std::default::Default;
use default_variant::default;
use ratatui_image::picker::{Capability, ProtocolType};
use ratatui_image::FontSize;
use serde::{Deserialize, Serialize};
use strum::Display;
pub const DEFAULT_FONT_SIZE: FontSize = (12, 12);
#[derive(Debug, Default, Clone)]
pub struct TerminalInfo {
kind: TerminalKind,
font_size: Option<FontSize>,
}
impl TerminalInfo {
/// Get the terminal kind.
pub fn kind(&self) -> &TerminalKind {
&self.kind
}
/// Get the font size.
pub fn font_size(&self) -> FontSize {
self.font_size.unwrap_or(DEFAULT_FONT_SIZE)
}
/// Sets the terminal kind, if currently unset (i.e., unprobed).
pub fn set_kind(&mut self, kind: TerminalKind) {
if matches!(self.kind, TerminalKind::Unsupported(UnsupportedReason::Unprobed)) {
self.kind = kind;
}
}
/// Sets the font size.
pub fn set_font_size(&mut self, font_size: FontSize) {
self.font_size = Some(font_size);
}
}
#[derive(Debug, Deserialize, Serialize, Display, Clone /*, Copy */)]
#[default(Unsupported(UnsupportedReason::default()))]
#[strum(serialize_all = "lowercase")]
pub enum TerminalKind {
Ghostty,
Hyper,
ITerm2,
Kitty,
MinTty,
Rio,
Tabby,
Vscode,
Wezterm,
Unsupported(UnsupportedReason),
}
#[derive(Debug, Default, Deserialize, Serialize, Clone, Copy)]
pub enum UnsupportedReason {
/// Terminal emulator does not provide real pixel size, making it impossible to calculate
/// font size.
///
/// Currently known terminal emulators which exhibit this behavior:
///
/// - VSCode
Unsized,
/// Terminal emulator is not known.
Unknown,
/// Terminal emulator has not been detected yet. This is only set during SSH initialization.
#[default]
Unprobed,
}
impl TerminalKind {
pub const ALL_SUPPORTED: [Self; 9] = [
Self::Ghostty,
Self::Hyper,
Self::ITerm2,
Self::Kitty,
Self::MinTty,
Self::Rio,
Self::Tabby,
Self::Vscode,
Self::Wezterm,
];
pub fn from_term_program(program: &str) -> Self {
let terminals = [
("ghostty", Self::Ghostty),
("iTerm.app", Self::ITerm2),
("iTerm2", Self::ITerm2),
("WezTerm", Self::Wezterm),
("mintty", Self::MinTty),
("vscode", Self::Vscode),
("Tabby", Self::Tabby),
("Hyper", Self::Hyper),
("rio", Self::Rio),
];
for (term, variant) in terminals {
if program.contains(term) {
return variant;
}
}
Self::Unsupported(UnsupportedReason::Unknown)
}
pub fn supported() -> String {
Self::ALL_SUPPORTED.map(|term| term.to_string()).join(", ")
}
pub fn capabilities(&self) -> Vec<Capability> {
match *self {
Self::Hyper | Self::Vscode => vec![Capability::RectangularOps],
Self::Ghostty => vec![Capability::Kitty, Capability::RectangularOps],
Self::Tabby | Self::MinTty => vec![Capability::Sixel, Capability::RectangularOps],
Self::Rio => vec![Capability::Sixel, Capability::RectangularOps],
Self::ITerm2 | Self::Wezterm => {
vec![Capability::Sixel, Capability::Kitty, Capability::RectangularOps]
}
Self::Kitty => vec![
Capability::Kitty,
Capability::RectangularOps,
Capability::TextSizingProtocol, // !! TODO: THIS COULD BE SO FUCKING COOL FOR MARKDOWN HEADINGS !!
],
Self::Unsupported(_) => vec![],
}
}
pub fn as_protocol(&self) -> ProtocolType {
if matches!(
self,
Self::ITerm2
| Self::Wezterm
| Self::MinTty
| Self::Vscode
| Self::Tabby
| Self::Hyper
| Self::Rio
) {
return ProtocolType::Iterm2;
} else if self.capabilities().contains(&Capability::Kitty) {
return ProtocolType::Kitty;
} else if self.capabilities().contains(&Capability::Sixel) {
return ProtocolType::Sixel;
}
ProtocolType::Halfblocks
}
}

View file

@ -6,7 +6,7 @@
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.2.5", "@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0", "@eslint/js": "^9.18.0",
"@sveltejs/adapter-static": "^3.0.9", "@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/kit": "^2.22.0", "@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0", "@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
@ -173,7 +173,7 @@
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.5", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ=="], "@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.5", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ=="],
"@sveltejs/adapter-static": ["@sveltejs/adapter-static@3.0.9", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-aytHXcMi7lb9ljsWUzXYQ0p5X1z9oWud2olu/EpmH7aCu4m84h7QLvb5Wp+CFirKcwoNnYvYWhyP/L8Vh1ztdw=="], "@sveltejs/adapter-auto": ["@sveltejs/adapter-auto@6.1.0", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-shOuLI5D2s+0zTv2ab5M5PqfknXqWbKi+0UwB9yLTRIdzsK1R93JOO8jNhIYSHdW+IYXIYnLniu+JZqXs7h9Wg=="],
"@sveltejs/kit": ["@sveltejs/kit@2.28.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.1.0", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-qrhygwHV5r6JrvCw4gwNqqxYGDi5YbajocxfKgFXmSFpFo8wQobUvsM0OfakN4h+0LEmXtqHRrC6BcyAkOwyoQ=="], "@sveltejs/kit": ["@sveltejs/kit@2.28.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.1.0", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-qrhygwHV5r6JrvCw4gwNqqxYGDi5YbajocxfKgFXmSFpFo8wQobUvsM0OfakN4h+0LEmXtqHRrC6BcyAkOwyoQ=="],

View file

@ -16,7 +16,7 @@
"devDependencies": { "devDependencies": {
"@eslint/compat": "^1.2.5", "@eslint/compat": "^1.2.5",
"@eslint/js": "^9.18.0", "@eslint/js": "^9.18.0",
"@sveltejs/adapter-static": "^3.0.9", "@sveltejs/adapter-auto": "^6.0.0",
"@sveltejs/kit": "^2.22.0", "@sveltejs/kit": "^2.22.0",
"@sveltejs/vite-plugin-svelte": "^6.0.0", "@sveltejs/vite-plugin-svelte": "^6.0.0",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",

View file

@ -1 +0,0 @@
export const prerender = true;

View file

@ -1,4 +1,4 @@
import adapter from '@sveltejs/adapter-static'; import adapter from '@sveltejs/adapter-auto';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */