Compare commits

..

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

25 changed files with 576 additions and 2760 deletions

2283
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -17,17 +17,9 @@ blog = [
"dep:reqwest", "dep:reqwest",
"dep:ipld-core", "dep:ipld-core",
"dep:tui-markdown", "dep:tui-markdown",
"dep:chrono", "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" actix-web = "4.11.0"
anyhow = "1.0.90" anyhow = "1.0.90"
@ -50,13 +42,11 @@ clap = { version = "4.5.20", features = [
color-eyre = "0.6.3" color-eyre = "0.6.3"
config = "0.15.14" config = "0.15.14"
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"
@ -65,7 +55,6 @@ libc = "0.2.161"
mime_guess = "2.0.5" 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"] } rust-embed = { version = "8.7.2", features = ["actix"] }
@ -84,6 +73,5 @@ 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

@ -18,10 +18,6 @@ const SSH_KEY_ALGOS: &[(&str, Algorithm)] = &[
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());
@ -36,8 +32,8 @@ fn main() -> Result<()> {
continue; continue;
} }
let key = let key = PrivateKey::random(&mut rng, algo.to_owned())
PrivateKey::random(&mut rng, algo.to_owned()).map_err(anyhow::Error::from)?; .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 +1 @@
[toolchain] nightly-2025-07-30
channel = "nightly-2025-03-28"
components = ["clippy", "rust-analyzer", "cargo", "rustc"]

View file

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

View file

@ -1,7 +1,6 @@
use std::fmt; use std::fmt;
use serde::de::{self, Visitor}; use serde::{de::{self, Visitor}, Deserialize, Deserializer, Serialize};
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)]
@ -31,8 +30,8 @@ pub enum Action {
impl<'de> Deserialize<'de> for Action { impl<'de> Deserialize<'de> for Action {
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>, { {
struct ActionVisitor; struct ActionVisitor;
impl<'de> Visitor<'de> for ActionVisitor { impl<'de> Visitor<'de> for ActionVisitor {
@ -43,8 +42,8 @@ impl<'de> Deserialize<'de> for Action {
} }
fn visit_str<E>(self, v: &str) -> Result<Action, E> fn visit_str<E>(self, v: &str) -> Result<Action, E>
where where E: de::Error
E: de::Error, { {
if v == "Continue" { if v == "Continue" {
Ok(Action::Continue(None)) Ok(Action::Continue(None))
} else { } else {

View file

@ -1,6 +1,5 @@
use std::sync::atomic::AtomicUsize; use std::sync::atomic::AtomicUsize;
use std::sync::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};
@ -18,14 +17,12 @@ use crate::action::Action;
use crate::components::*; use crate::components::*;
use crate::config::Config; use crate::config::Config;
use crate::keycode::KeyCodeExt; use crate::keycode::KeyCodeExt;
use crate::tui::terminal::{TerminalInfo, TerminalKind, UnsupportedReason};
use crate::tui::{Event, Terminal, Tui}; use crate::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,
@ -47,7 +44,9 @@ pub struct App {
blog_posts: Arc<Mutex<BlogPosts>>, blog_posts: Arc<Mutex<BlogPosts>>,
} }
#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(
Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize,
)]
pub enum Mode { pub enum Mode {
#[default] #[default]
Home, Home,
@ -57,7 +56,6 @@ impl App {
pub const MIN_TUI_DIMS: (u16, u16) = (105, 25); 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>>,
@ -83,7 +81,6 @@ impl App {
))); )));
Ok(Self { Ok(Self {
terminal_info,
tick_rate, tick_rate,
frame_rate, frame_rate,
should_quit: false, should_quit: false,
@ -114,7 +111,9 @@ impl App {
) -> Result<()> { ) -> Result<()> {
let mut tui = tui.write().await; let mut tui = tui.write().await;
let tui = tui.get_or_insert( let 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 // Force the dimensions to be validated before rendering anything by sending a `Resize` event
@ -126,33 +125,43 @@ impl App {
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())?;
self.content
.try_lock()?
.register_action_handler(self.action_tx.clone())?;
self.cat
.try_lock()?
.register_action_handler(self.action_tx.clone())?;
#[cfg(feature = "blog")] #[cfg(feature = "blog")]
self.blog_posts.try_lock()?.register_action_handler(self.action_tx.clone())?; self.blog_posts
.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())?;
self.content
.try_lock()?
.register_config_handler(self.config.clone())?;
self.cat
.try_lock()?
.register_config_handler(self.config.clone())?;
#[cfg(feature = "blog")] #[cfg(feature = "blog")]
self.blog_posts.try_lock()?.register_config_handler(self.config.clone())?; self.blog_posts
.try_lock()?
while let TerminalKind::Unsupported(UnsupportedReason::Unprobed) = .register_config_handler(self.config.clone())?;
self.terminal_info.blocking_read().kind()
{
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)?;
#[cfg(feature = "blog")] #[cfg(feature = "blog")]
self.blog_posts.try_lock()?.init(self.terminal_info.clone(), size)?; self.cat.try_lock()?.init(size)?;
self.blog_posts.try_lock()?.init(size)?;
Ok::<_, eyre::Error>(()) Ok::<_, eyre::Error>(())
})?; })?;
@ -258,7 +267,8 @@ impl App {
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.needs_resize = w < Self::MIN_TUI_DIMS.0 || h < Self::MIN_TUI_DIMS.1; self.needs_resize =
w < Self::MIN_TUI_DIMS.0 || h < Self::MIN_TUI_DIMS.1;
self.resize(tui, w, h)?; self.resize(tui, w, h)?;
} }
Action::Render => self.render(tui)?, Action::Render => self.render(tui)?,
@ -266,10 +276,14 @@ impl App {
} }
// Update each component // Update each component
if let Some(action) = self.tabs.try_lock()?.update(action.clone())? { if let Some(action) =
self.tabs.try_lock()?.update(action.clone())?
{
self.action_tx.send(action)?; self.action_tx.send(action)?;
} }
if let Some(action) = self.content.try_lock()?.update(action.clone())? { if let Some(action) =
self.content.try_lock()?.update(action.clone())?
{
self.action_tx.send(action)?; self.action_tx.send(action)?;
} }
if let Some(action) = self.cat.try_lock()?.update(action.clone())? { if let Some(action) = self.cat.try_lock()?.update(action.clone())? {
@ -277,7 +291,9 @@ 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.blog_posts.try_lock()?.update(action.clone())?
{
self.action_tx.send(action)?; self.action_tx.send(action)?;
} }
} }
@ -329,9 +345,8 @@ impl App {
frame.render_widget(Clear, area); frame.render_widget(Clear, area);
frame.render_widget( frame.render_widget(
Paragraph::new( Paragraph::new(
Line::from(error_message.clone()).style( Line::from(error_message.clone())
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), .style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
),
) )
.alignment(Alignment::Center) .alignment(Alignment::Center)
.wrap(Wrap { trim: false }), .wrap(Wrap { trim: false }),
@ -345,7 +360,9 @@ impl App {
term.try_draw(|frame| { 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(),
)
.split(frame.area()); .split(frame.area());
// Render the domain name text // Render the domain name text
@ -356,11 +373,17 @@ impl App {
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(std::io::Error::other)?;
tabs.draw( tabs.draw(
frame, frame,
@ -416,10 +439,14 @@ 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 \
tab.", to view this tab.",
) )
.style(Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)); .style(
Style::default()
.fg(Color::Red)
.add_modifier(Modifier::BOLD),
);
frame.render_widget(placeholder, content_rect); frame.render_widget(placeholder, content_rect);
} }

View file

@ -28,20 +28,30 @@ pub mod blog {
lazy_static! { lazy_static! {
static ref POSTS_CACHE_STORE: MemoryStore<usize, (Instant, com::whtwnd::blog::entry::Record)> = static ref POSTS_CACHE_STORE: MemoryStore<usize, (Instant, com::whtwnd::blog::entry::Record)> =
MemoryStore::default(); MemoryStore::default();
static ref AGENT: Agent<CredentialSession<MemoryStore<(), Object<SessionOutputData>>, ReqwestClient>> = static ref AGENT: Agent<
Agent::new(CredentialSession::new( CredentialSession<
ReqwestClient::new("https://bsky.social"), MemoryStore<(), Object<SessionOutputData>>,
MemorySessionStore::default(), ReqwestClient,
)); >,
> = Agent::new(CredentialSession::new(
ReqwestClient::new("https://bsky.social"),
MemorySessionStore::default(),
));
} }
#[instrument(level = "debug")] #[instrument(level = "debug")]
pub async fn get_all_posts() -> Result<Vec<com::whtwnd::blog::entry::Record>> { pub async fn get_all_posts() -> Result<Vec<com::whtwnd::blog::entry::Record>>
{
let mut i = 0; let mut i = 0;
let mut posts = Vec::new(); let mut posts = Vec::new();
while let Some((cache_creation_time, post)) = POSTS_CACHE_STORE.get(&i).await? { while let Some((cache_creation_time, post)) =
POSTS_CACHE_STORE.get(&i).await?
{
if cache_creation_time.elapsed() > CACHE_INVALIDATION_PERIOD { if cache_creation_time.elapsed() > CACHE_INVALIDATION_PERIOD {
tracing::info!("Cache for post #{} is stale, fetching new posts", i); tracing::info!(
"Cache for post #{} is stale, fetching new posts",
i
);
POSTS_CACHE_STORE.clear().await?; POSTS_CACHE_STORE.clear().await?;
return fetch_posts_into_cache().await; return fetch_posts_into_cache().await;
} }
@ -52,7 +62,9 @@ pub mod blog {
} }
if posts.is_empty() { if posts.is_empty() {
tracing::info!("No blog posts found in cache, fetching from ATProto"); tracing::info!(
"No blog posts found in cache, fetching from ATProto"
);
return fetch_posts_into_cache().await; return fetch_posts_into_cache().await;
} }
@ -60,7 +72,8 @@ pub mod blog {
} }
#[instrument(level = "trace")] #[instrument(level = "trace")]
async fn fetch_posts_into_cache() -> Result<Vec<com::whtwnd::blog::entry::Record>> { async fn fetch_posts_into_cache(
) -> Result<Vec<com::whtwnd::blog::entry::Record>> {
let records = &AGENT let records = &AGENT
.api .api
.com .com
@ -87,7 +100,9 @@ pub mod blog {
.map(|elem| { .map(|elem| {
if let Unknown::Object(btree_map) = &elem.data.value { if let Unknown::Object(btree_map) = &elem.data.value {
let ser = serde_json::to_string(&btree_map)?; let ser = serde_json::to_string(&btree_map)?;
let des = serde_json::from_str::<com::whtwnd::blog::entry::Record>(&ser)?; let des = serde_json::from_str::<
com::whtwnd::blog::entry::Record,
>(&ser)?;
return Ok(des); return Ok(des);
} }

View file

@ -1,15 +1,11 @@
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::layout::{Rect, Size};
use ratatui::Frame; use ratatui::Frame;
use tokio::sync::mpsc::UnboundedSender; use tokio::sync::mpsc::UnboundedSender;
use tokio::sync::RwLock;
use crate::action::Action; use crate::action::Action;
use crate::config::Config; use crate::config::Config;
use crate::tui::terminal::TerminalInfo;
use crate::tui::Event; use crate::tui::Event;
// //
@ -45,7 +41,10 @@ pub trait Component: Send {
/// # Returns /// # Returns
/// ///
/// * `Result<()>` - An Ok result or an error. /// * `Result<()>` - An Ok result or an error.
fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> Result<()> { fn register_action_handler(
&mut self,
tx: UnboundedSender<Action>,
) -> Result<()> {
let _ = tx; // to appease clippy let _ = tx; // to appease clippy
Ok(()) Ok(())
} }
@ -62,7 +61,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 +70,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.
@ -84,10 +83,15 @@ pub trait Component: Send {
/// # Returns /// # Returns
/// ///
/// * `Result<Option<Action>>` - An action to be processed or none. /// * `Result<Option<Action>>` - An action to be processed or none.
fn handle_events(&mut self, event: Option<Event>) -> Result<Option<Action>> { fn handle_events(
&mut self,
event: Option<Event>,
) -> Result<Option<Action>> {
let action = match event { let action = match event {
Some(Event::Key(key_event)) => self.handle_key_event(key_event)?, Some(Event::Key(key_event)) => self.handle_key_event(key_event)?,
Some(Event::Mouse(mouse_event)) => self.handle_mouse_event(mouse_event)?, Some(Event::Mouse(mouse_event)) => {
self.handle_mouse_event(mouse_event)?
}
_ => None, _ => None,
}; };
Ok(action) Ok(action)
@ -114,7 +118,10 @@ pub trait Component: Send {
/// # Returns /// # Returns
/// ///
/// * `Result<Option<Action>>` - An action to be processed or none. /// * `Result<Option<Action>>` - An action to be processed or none.
fn handle_mouse_event(&mut self, mouse: MouseEvent) -> Result<Option<Action>> { fn handle_mouse_event(
&mut self,
mouse: MouseEvent,
) -> Result<Option<Action>> {
let _ = mouse; // to appease clippy let _ = mouse; // to appease clippy
Ok(None) Ok(None)
} }

View file

@ -1,30 +1,19 @@
use std::io::{BufReader, Cursor};
use std::sync::Arc; use std::sync::Arc;
use color_eyre::eyre::eyre;
use color_eyre::Result; use color_eyre::Result;
use image::{ImageReader, Rgba}; use ratatui::widgets::Widget;
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::mpsc::UnboundedSender;
use tokio::sync::RwLock;
use crate::action::Action; use crate::action::Action;
use crate::com; use crate::com;
use crate::com::whtwnd::blog::defs::Ogp;
use crate::components::{Component, SelectionList}; 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 type Post = Arc<com::whtwnd::blog::entry::Record>;
#[derive(Debug)]
pub struct BlogPosts { pub struct BlogPosts {
list: SelectionList<Post>, list: SelectionList<Post>,
posts: Vec<Post>, posts: Vec<Post>,
image_renderer: Option<Picker>, in_post: Option<usize>,
in_post: (Option<StatefulProtocol>, Option<usize>),
} }
impl BlogPosts { impl BlogPosts {
@ -32,73 +21,28 @@ impl BlogPosts {
let posts_ref = posts.to_vec(); let posts_ref = posts.to_vec();
Self { Self {
list: SelectionList::new(posts), 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, posts: posts_ref,
in_post: (None, None), in_post: None,
} }
} }
pub fn is_in_post(&self) -> bool { pub fn is_in_post(&self) -> bool {
self.in_post.1.is_some() self.in_post.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 { impl Component for BlogPosts {
fn init(&mut self, term_info: Arc<RwLock<TerminalInfo>>, _: Size) -> Result<()> { fn register_config_handler(
let locked_info = term_info.blocking_read().clone(); &mut self,
config: crate::config::Config,
if matches!(locked_info.kind(), TerminalKind::Unsupported(UnsupportedReason::Unsized)) ) -> Result<()> {
{
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) self.list.register_config_handler(config)
} }
fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> Result<()> { fn register_action_handler(
&mut self,
tx: UnboundedSender<Action>,
) -> Result<()> {
self.list.register_action_handler(tx) self.list.register_action_handler(tx)
} }
@ -108,9 +52,10 @@ impl Component for BlogPosts {
Action::Tick => {} Action::Tick => {}
Action::Render => {} Action::Render => {}
// FIXME: do we reload the image on every single render of a post? Action::Quit | Action::PrevTab | Action::NextTab => {
Action::Quit | Action::PrevTab | Action::NextTab => self.in_post = (None, None), self.in_post = None
Action::Continue(post_id) => self.in_post.1 = post_id, }
Action::Continue(post_id) => self.in_post = post_id,
_ => {} _ => {}
}; };
@ -122,70 +67,16 @@ impl Component for BlogPosts {
frame: &mut ratatui::Frame, frame: &mut ratatui::Frame,
area: ratatui::prelude::Rect, area: ratatui::prelude::Rect,
) -> Result<()> { ) -> Result<()> {
if let Some(post_id_inner) = self.in_post.1 { if let Some(post_id_inner) = self.in_post {
let post = self let post_body = self
.posts .posts
.get(post_id_inner) .get(post_id_inner)
.ok_or(eyre!("Current post apparently doesn't exist"))?; .map_or(String::from("404 - Blog not found!"), |post| {
post.content.clone()
});
let post_body = post.title.clone().map_or(post.content.clone(), |title| { let post_widget = tui_markdown::from_str(&post_body);
format!("# {}\n\n{}", title, post.content) post_widget.render(area, frame.buffer_mut());
});
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 { } else {
self.list.draw(frame, area)?; self.list.draw(frame, area)?;
} }

View file

@ -28,7 +28,10 @@ impl Cat {
} }
impl Component for Cat { impl Component for Cat {
fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> Result<()> { fn register_action_handler(
&mut self,
tx: UnboundedSender<Action>,
) -> Result<()> {
self.command_tx = Some(tx); self.command_tx = Some(tx);
Ok(()) Ok(())
} }
@ -54,7 +57,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

@ -14,12 +14,6 @@ use crate::action::Action;
use crate::components::Post; use crate::components::Post;
use crate::config::Config; 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 {
command_tx: Option<UnboundedSender<Action>>, command_tx: Option<UnboundedSender<Action>>,
@ -41,8 +35,11 @@ impl Content {
.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()
@ -57,7 +54,10 @@ impl Content {
"she/they", "she/they",
Style::default().add_modifier(Modifier::ITALIC), 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))
@ -69,7 +69,9 @@ impl Content {
Line::from(""), Line::from(""),
Line::from(vec![ Line::from(vec![
Span::from(" "), Span::from(" "),
Span::from("I specialize in systems programming, primarily in "), Span::from(
"I specialize in systems programming, primarily in ",
),
Span::styled( Span::styled(
"Rust 🦀", "Rust 🦀",
Style::default() Style::default()
@ -87,79 +89,98 @@ impl Content {
]), ]),
Line::from(""), Line::from(""),
Line::from( Line::from(
" I am an avid believer of open-source software, and contribute to a few \ " I am an avid believer of open-source software, and \
projects such as:", contribute to a few projects such as:",
), ),
]); ]);
let projects = vec![ let projects = vec![
( (
Style::default().fg(Color::LightMagenta).add_modifier(Modifier::BOLD), Style::default()
.fg(Color::LightMagenta)
.add_modifier(Modifier::BOLD),
"lune-org/lune: A standalone Luau runtime", "lune-org/lune: A standalone Luau runtime",
), ),
( (
Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD), Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD),
"DiscordLuau/discord-luau: A Luau library for creating Discord bots, powered \ "DiscordLuau/discord-luau: A Luau library for creating \
by Lune", Discord bots, powered by Lune",
), ),
( (
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
"pesde-pkg/pesde: A package manager for the Luau programming language, \ "pesde-pkg/pesde: A package manager for the Luau programming \
supporting multiple runtimes including Roblox and Lune", 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);
let bullet = ""; let bullet = "";
let indent = " "; let indent = " ";
let first_line = if project.len() > area.width as usize - bullet.len() { let first_line =
let split_point = project if project.len() > area.width as usize - bullet.len() {
.char_indices() let split_point = project
.take_while(|(i, _)| *i < area.width as usize - bullet.len()) .char_indices()
.last() .take_while(|(i, _)| {
.map(|(i, _)| i) *i < area.width as usize - bullet.len()
.unwrap_or(project.len()); })
let (first, rest) = project.split_at(split_point); .last()
content.push(Line::from(vec![ .map(|(i, _)| i)
Span::from(bullet), .unwrap_or(project.len());
formatted_left, let (first, rest) = project.split_at(split_point);
Span::from(":"), content.push(Line::from(vec![
Span::styled( Span::from(bullet),
first.trim_start_matches(format!("{left}:").as_str()).to_string(), formatted_left,
Style::default().fg(Color::White), Span::from(":"),
), Span::styled(
])); first
rest.to_string() .trim_start_matches(format!("{left}:").as_str())
} else { .to_string(),
content.push(Line::from(vec![ Style::default().fg(Color::White),
Span::from(bullet), ),
formatted_left, ]));
Span::from(":"), rest.to_string()
Span::styled(right.to_string(), Style::default().fg(Color::White)), } else {
])); content.push(Line::from(vec![
String::new() 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; let mut remaining_text = first_line;
while !remaining_text.is_empty() { while !remaining_text.is_empty() {
if remaining_text.len() > area.width as usize - indent.len() { if remaining_text.len() > area.width as usize - indent.len() {
let split_point = remaining_text let split_point = remaining_text
.char_indices() .char_indices()
.take_while(|(i, _)| *i < area.width as usize - indent.len()) .take_while(|(i, _)| {
*i < area.width as usize - indent.len()
})
.last() .last()
.map(|(i, _)| i) .map(|(i, _)| i)
.unwrap_or(remaining_text.len()); .unwrap_or(remaining_text.len());
let (first, rest) = remaining_text.split_at(split_point); let (first, rest) = remaining_text.split_at(split_point);
content.push(Line::from(vec![ content.push(Line::from(vec![
Span::from(indent), Span::from(indent),
Span::styled(first.to_string(), Style::default().fg(Color::White)), Span::styled(
first.to_string(),
Style::default().fg(Color::White),
),
])); ]));
remaining_text = rest.to_string(); remaining_text = rest.to_string();
} else { } else {
@ -178,8 +199,8 @@ 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 \
:3", super adorable :3",
), ),
]); ]);
@ -203,7 +224,10 @@ impl Content {
} }
impl Component for Content { impl Component for Content {
fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> Result<()> { fn register_action_handler(
&mut self,
tx: UnboundedSender<Action>,
) -> Result<()> {
self.command_tx = Some(tx); self.command_tx = Some(tx);
Ok(()) Ok(())
} }
@ -233,7 +257,9 @@ impl Component for Content {
// 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(
@ -245,11 +271,17 @@ impl Component for Content {
let mut current_pos = 1 + devcomp_width; let mut current_pos = 1 + devcomp_width;
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) =
("", Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)) if i == self.selected_tab.load(Ordering::Relaxed) {
} else { (
("", Style::default().fg(Color::DarkGray)) "",
}; Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
)
} else {
("", Style::default().fg(Color::DarkGray))
};
let default_style = Style::default().fg(Color::DarkGray); let default_style = Style::default().fg(Color::DarkGray);
@ -267,15 +299,19 @@ 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) + "",
Style::default().fg(Color::DarkGray), Style::default().fg(Color::DarkGray),
)); ));
let border_left = Span::styled("", Style::default().fg(Color::DarkGray)); let border_left =
let border_right = Span::styled("", Style::default().fg(Color::DarkGray)); Span::styled("", Style::default().fg(Color::DarkGray));
let border_right =
Span::styled("", Style::default().fg(Color::DarkGray));
// Render the content // Render the content
let content_widget = Paragraph::new(content) let content_widget = Paragraph::new(content)
@ -300,7 +336,12 @@ impl Component for Content {
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 {
@ -311,7 +352,12 @@ impl Component for Content {
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,13 +1,20 @@
use chrono::DateTime; use chrono::DateTime;
use color_eyre::eyre::Result; use color_eyre::eyre::Result;
use ratatui::prelude::*; use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::*; use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, List, ListItem, ListState};
use tokio::sync::mpsc::UnboundedSender; use tokio::sync::mpsc::UnboundedSender;
use crate::action::Action; use crate::action::Action;
use crate::components::{Component, Post}; use crate::components::{Component, Post};
use crate::config::Config; use crate::config::Config;
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(Debug)] #[derive(Debug)]
pub struct SelectionList<T> { pub struct SelectionList<T> {
config: Config, config: Config,
@ -99,13 +106,8 @@ impl Component for SelectionList<Post> {
]; ];
let subtitle_span = Span::raw( let subtitle_span = Span::raw(
[ [" ", post.subtitle.as_ref().unwrap_or(&truncate(post.content.as_ref(), 40))]
" ", .concat(),
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(), &[subtitle_span]].concat()));

View file

@ -19,7 +19,10 @@ 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() }
} }
@ -41,7 +44,10 @@ impl Tabs {
} }
impl Component for Tabs { impl Component for Tabs {
fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> Result<()> { fn register_action_handler(
&mut self,
tx: UnboundedSender<Action>,
) -> Result<()> {
self.command_tx = Some(tx); self.command_tx = Some(tx);
Ok(()) Ok(())
} }
@ -78,13 +84,17 @@ 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]
.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(format!(" {} ", tab), style));
tab_lines[1].spans.push(Span::styled("", Style::default().fg(Color::DarkGray))); tab_lines[1]
.spans
.push(Span::styled("", Style::default().fg(Color::DarkGray)));
} }
let tabs_widget = let tabs_widget = Paragraph::new(tab_lines)
Paragraph::new(tab_lines).block(Block::default().borders(Borders::NONE)); .block(Block::default().borders(Borders::NONE));
frame.render_widget( frame.render_widget(
tabs_widget, tabs_widget,

View file

@ -38,11 +38,16 @@ 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,15 +68,19 @@ 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
} }
} }
if !found_config { if !found_config {
error!("No configuration file found. Application may not behave as expected"); error!(
"No configuration file found. Application may not behave as \
expected"
);
} }
let mut cfg: Self = builder.build()?.try_deserialize()?; let mut cfg: Self = builder.build()?.try_deserialize()?;
@ -126,14 +135,18 @@ 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
.into_iter() .into_iter()
.map(|(mode, inner_map)| { .map(|(mode, inner_map)| {
let converted_inner_map = inner_map let converted_inner_map = inner_map
.into_iter() .into_iter()
.map(|(key_str, cmd)| (parse_key_sequence(&key_str).unwrap(), cmd)) .map(|(key_str, cmd)| {
(parse_key_sequence(&key_str).unwrap(), cmd)
})
.collect(); .collect();
(mode, converted_inner_map) (mode, converted_inner_map)
}) })
@ -288,7 +301,9 @@ pub fn key_event_to_string(key_event: &KeyEvent) -> String {
} }
pub fn parse_key_sequence(raw: &str) -> Result<Vec<KeyEvent>, String> { pub fn parse_key_sequence(raw: &str) -> Result<Vec<KeyEvent>, String> {
if raw.chars().filter(|c| *c == '>').count() != raw.chars().filter(|c| *c == '<').count() { if raw.chars().filter(|c| *c == '>').count()
!= raw.chars().filter(|c| *c == '<').count()
{
return Err(format!("Unable to parse `{}`", raw)); return Err(format!("Unable to parse `{}`", raw));
} }
let raw = if !raw.contains("><") { let raw = if !raw.contains("><") {
@ -321,7 +336,9 @@ 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
.into_iter() .into_iter()
@ -388,12 +405,16 @@ fn parse_color(s: &str) -> Option<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 =
let green = (s.as_bytes()[4] as char).to_digit(10).unwrap_or_default() as u8; (s.as_bytes()[3] as char).to_digit(10).unwrap_or_default() as u8;
let blue = (s.as_bytes()[5] as char).to_digit(10).unwrap_or_default() as u8; let green =
(s.as_bytes()[4] as char).to_digit(10).unwrap_or_default() as u8;
let blue =
(s.as_bytes()[5] as char).to_digit(10).unwrap_or_default() as u8;
let c = 16 + red * 36 + green * 6 + blue; let c = 16 + red * 36 + green * 6 + blue;
Some(Color::Indexed(c)) Some(Color::Indexed(c))
} else if s == "bold black" { } else if s == "bold black" {
@ -466,7 +487,8 @@ mod tests {
#[test] #[test]
fn test_process_color_string() { fn test_process_color_string() {
let (color, modifiers) = process_color_string("underline bold inverse gray"); let (color, modifiers) =
process_color_string("underline bold inverse gray");
assert_eq!(color, "gray"); assert_eq!(color, "gray");
assert!(modifiers.contains(Modifier::UNDERLINED)); assert!(modifiers.contains(Modifier::UNDERLINED));
assert!(modifiers.contains(Modifier::BOLD)); assert!(modifiers.contains(Modifier::BOLD));
@ -540,12 +562,18 @@ 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!(
parse_key_event("ctrl-shift-enter").unwrap(), parse_key_event("ctrl-shift-enter").unwrap(),
KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT) KeyEvent::new(
KeyCode::Enter,
KeyModifiers::CONTROL | KeyModifiers::SHIFT
)
); );
} }

View file

@ -21,8 +21,9 @@ pub fn init() -> Result<()> {
let metadata = metadata!(); let metadata = metadata!();
let file_path = handle_dump(&metadata, panic_info); let file_path = handle_dump(&metadata, panic_info);
// prints human-panic message // prints human-panic message
print_msg(file_path, &metadata) print_msg(file_path, &metadata).expect(
.expect("human-panic: printing error message to console failed"); "human-panic: printing error message to console failed",
);
eprintln!("{}", panic_hook.panic_report(panic_info)); // prints color-eyre stack trace to stderr eprintln!("{}", panic_hook.panic_report(panic_info)); // prints color-eyre stack trace to stderr
} }
let msg = format!("{}", panic_hook.panic_report(panic_info)); let msg = format!("{}", panic_hook.panic_report(panic_info));

View file

@ -99,9 +99,15 @@ impl KeyCodeExt for KeyCode {
fn into_key_event(self) -> KeyEvent { fn into_key_event(self) -> KeyEvent {
match self { match self {
Self::Char(CTRL_C) => KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL), Self::Char(CTRL_C) => {
Self::Char(CTRL_D) => KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL), KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)
Self::Char(CTRL_Z) => KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL), }
Self::Char(CTRL_D) => {
KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL)
}
Self::Char(CTRL_Z) => {
KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL)
}
other => KeyEvent::new(other, KeyModifiers::empty()), other => KeyEvent::new(other, KeyModifiers::empty()),
} }
} }

View file

@ -10,7 +10,11 @@ macro_rules! embedded_route {
($path:expr) => { ($path:expr) => {
match WebLandingServer::get($path) { match WebLandingServer::get($path) {
Some(content) => HttpResponse::Ok() Some(content) => HttpResponse::Ok()
.content_type(mime_guess::from_path($path).first_or_octet_stream().as_ref()) .content_type(
mime_guess::from_path($path)
.first_or_octet_stream()
.as_ref(),
)
.body(content.data.into_owned()), .body(content.data.into_owned()),
None => HttpResponse::NotFound().body("404 Not Found"), None => HttpResponse::NotFound().body("404 Not Found"),
} }
@ -41,7 +45,11 @@ impl WebLandingServer {
tracing::info!("Web server listening on {}", addr); tracing::info!("Web server listening on {}", addr);
HttpServer::new(|| { HttpServer::new(|| {
// TODO: register a default service for a nicer 404 page // TODO: register a default service for a nicer 404 page
App::new().service(index).service(favicon).service(dist).wrap(Logger::default()) App::new()
.service(index)
.service(favicon)
.service(dist)
.wrap(Logger::default())
}) })
.bind(addr)? .bind(addr)?
.run() .run()

View file

@ -30,16 +30,20 @@ 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::DEBUG.into() if cfg!(debug_assertions) {
} else { tracing::Level::DEBUG.into()
tracing::Level::INFO.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() .try_from_env()
.unwrap_or_else(|_| env_filter.with_env_var(LOG_ENV.to_string()).from_env_lossy()) .unwrap_or_else(|_| {
env_filter.with_env_var(LOG_ENV.to_string()).from_env_lossy()
})
.add_directive("russh::cipher=info".parse().unwrap()) .add_directive("russh::cipher=info".parse().unwrap())
.add_directive("tui_markdown=info".parse().unwrap()); .add_directive("tui_markdown=info".parse().unwrap());
@ -82,7 +86,9 @@ pub fn init() -> Result<()> {
let layer = layer let layer = layer
.compact() .compact()
.without_time() .without_time()
.with_span_events(tracing_subscriber::fmt::format::FmtSpan::NONE) .with_span_events(
tracing_subscriber::fmt::format::FmtSpan::NONE,
)
.with_target(false) .with_target(false)
.with_thread_ids(false); .with_thread_ids(false);
layer layer

View file

@ -50,8 +50,10 @@ 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 ssh_socket_addr =
let web_server_addr = WEB_SERVER_ADDR.ok_or(eyre!("Invalid host IP provided"))?; SSH_SOCKET_ADDR.ok_or(eyre!("Invalid host IP provided"))?;
let web_server_addr =
WEB_SERVER_ADDR.ok_or(eyre!("Invalid host IP provided"))?;
tokio::select! { tokio::select! {
ssh_res = SshServer::start(ssh_socket_addr, ssh_config()) => ssh_res, ssh_res = SshServer::start(ssh_socket_addr, ssh_config()) => ssh_res,
@ -66,9 +68,9 @@ pub fn host_ip() -> Result<[u8; 4]> {
.host .host
.splitn(4, ".") .splitn(4, ".")
.map(|octet_str| { .map(|octet_str| {
octet_str octet_str.parse::<u8>().map_err(|_| {
.parse::<u8>() eyre!("Octet component out of range (expected u8)")
.map_err(|_| eyre!("Octet component out of range (expected u8)")) })
}) })
.collect::<Result<Vec<u8>>>()?, .collect::<Result<Vec<u8>>>()?,
) )

View file

@ -13,7 +13,6 @@ use tracing::instrument;
use crate::app::App; use crate::app::App;
use crate::tui::backend::SshBackend; use crate::tui::backend::SshBackend;
use crate::tui::terminal::{TerminalInfo, TerminalKind, UnsupportedReason};
use crate::tui::{Terminal, Tui}; use crate::tui::{Terminal, Tui};
use crate::OPTIONS; use crate::OPTIONS;
@ -51,7 +50,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())
} }
@ -65,7 +67,6 @@ impl Write for TermWriter {
#[allow(clippy::type_complexity)] #[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 +81,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 +100,16 @@ 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?; app.lock_owned().await.run(writer, tui).await?;
session.close(channel_id).await.map_err(|_| eyre!("failed to close session"))?; 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,28 +142,26 @@ 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 = async || -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
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(()) };
};
match result().await { match result().await {
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}");
let _ = session_handle.channel_failure(channel_id).await; let _ =
session_handle.channel_failure(channel_id).await;
} }
} }
}); });
@ -175,31 +172,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,
@ -213,19 +185,10 @@ impl Handler for SshSession {
session: &mut Session, session: &mut Session,
) -> Result<(), Self::Error> { ) -> Result<(), Self::Error> {
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} * \
if pix_width != 0 && pix_height != 0 { {pix_height}"
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)?;
@ -254,7 +217,10 @@ impl Handler for SshSession {
data: &[u8], data: &[u8],
_session: &mut Session, _session: &mut Session,
) -> Result<(), Self::Error> { ) -> Result<(), Self::Error> {
tracing::debug!("Received keystroke data from SSH: {:?}, sending", data); tracing::debug!(
"Received keystroke data from SSH: {:?}, sending",
data
);
self.keystroke_tx self.keystroke_tx
.send(data.to_vec()) .send(data.to_vec())
.map_err(|_| eyre!("Failed to send event keystroke data")) .map_err(|_| eyre!("Failed to send event keystroke data"))
@ -265,15 +231,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"))?;
@ -290,7 +253,9 @@ impl SshServer {
pub async fn start(addr: SocketAddr, config: Config) -> eyre::Result<()> { pub async fn start(addr: SocketAddr, config: Config) -> eyre::Result<()> {
let listener = TcpListener::bind(addr).await?; let listener = TcpListener::bind(addr).await?;
Self.run_on_socket(Arc::new(config), &listener).await.map_err(|err| eyre!(err)) Self.run_on_socket(Arc::new(config), &listener)
.await
.map_err(|err| eyre!(err))
} }
} }

View file

@ -49,7 +49,9 @@ impl Backend for SshBackend {
self.inner.show_cursor() self.inner.show_cursor()
} }
fn get_cursor_position(&mut self) -> io::Result<ratatui::prelude::Position> { fn get_cursor_position(
&mut self,
) -> io::Result<ratatui::prelude::Position> {
self.inner.get_cursor_position() self.inner.get_cursor_position()
} }

View file

@ -7,8 +7,8 @@ use backend::SshBackend;
use color_eyre::Result; use color_eyre::Result;
use crossterm::cursor; use crossterm::cursor;
use crossterm::event::{ use crossterm::event::{
DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture, DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste,
KeyEvent, MouseEvent, EnableMouseCapture, KeyEvent, MouseEvent,
}; };
use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen}; use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -23,7 +23,6 @@ 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 {
@ -118,8 +117,10 @@ impl Tui {
tick_rate: f64, tick_rate: f64,
frame_rate: f64, frame_rate: f64,
) { ) {
let mut tick_interval = interval(Duration::from_secs_f64(1.0 / tick_rate)); let mut tick_interval =
let mut render_interval = interval(Duration::from_secs_f64(1.0 / frame_rate)); interval(Duration::from_secs_f64(1.0 / tick_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");
@ -160,9 +161,14 @@ impl Tui {
}; };
if timeout(attempt_timeout, self.await_shutdown()).await.is_err() { if timeout(attempt_timeout, self.await_shutdown()).await.is_err() {
timeout(attempt_timeout, abort_shutdown).await.inspect_err(|_| { timeout(attempt_timeout, abort_shutdown).await.inspect_err(
error!("Failed to abort task in 100 milliseconds for unknown reason") |_| {
})?; error!(
"Failed to abort task in 100 milliseconds for unknown \
reason"
)
},
)?;
} }
Ok(()) Ok(())
@ -171,7 +177,11 @@ impl Tui {
pub fn enter(&mut self) -> Result<()> { pub fn enter(&mut self) -> Result<()> {
let mut term = self.terminal.try_lock()?; let mut term = self.terminal.try_lock()?;
// crossterm::terminal::enable_raw_mode()?; // TODO: Enable raw mode for pty // crossterm::terminal::enable_raw_mode()?; // TODO: Enable raw mode for pty
crossterm::execute!(term.backend_mut(), EnterAlternateScreen, cursor::Hide)?; crossterm::execute!(
term.backend_mut(),
EnterAlternateScreen,
cursor::Hide
)?;
if self.mouse { if self.mouse {
crossterm::execute!(term.backend_mut(), EnableMouseCapture)?; crossterm::execute!(term.backend_mut(), EnableMouseCapture)?;
@ -201,7 +211,11 @@ impl Tui {
crossterm::execute!(term.backend_mut(), DisableMouseCapture)?; crossterm::execute!(term.backend_mut(), DisableMouseCapture)?;
} }
crossterm::execute!(term.backend_mut(), LeaveAlternateScreen, cursor::Show)?; crossterm::execute!(
term.backend_mut(),
LeaveAlternateScreen,
cursor::Show
)?;
Ok(()) Ok(())
} }
@ -216,7 +230,8 @@ impl Tui {
// Update the status and initialize a cancellation token // Update the status and initialize a cancellation token
let token = Arc::new(CancellationToken::new()); let token = Arc::new(CancellationToken::new());
let suspension = Arc::new(Mutex::new(())); let suspension = Arc::new(Mutex::new(()));
*self.status.write().await = TuiStatus::Suspended(Arc::clone(&suspension)); *self.status.write().await =
TuiStatus::Suspended(Arc::clone(&suspension));
// Spawn a task holding on the lock until a notification interrupts it // Spawn a task holding on the lock until a notification interrupts it
let status = Arc::clone(&self.status); let status = Arc::clone(&self.status);
@ -247,7 +262,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,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
}
}