feat(blog): implement proper blog pages with full rendering

* Render blog headers using a patched `ratatui-image` to export internal
  `Picker` fields which we use to construct our own picker based on
  guesses for what capabilities a terminal might have based on
  `$TERM_PROGRAM` values
* Move truncate implementation into `content` module and have other
  modules import it
* Add `terminal` module under `tui` for classifying different terminals
  and storing information regarding them
* Update trait `Component::init` to supply a `TerminalInfo`, to help
  components adapt themselves to terminal emulator capabilities
* Move rust toolchain back to stable, now version 1.87
* Increase rustfmt max width and chain width to 95
This commit is contained in:
Erica Marigold 2025-08-18 19:03:03 +01:00
parent b878008c25
commit ee524dc160
Signed by: DevComp
SSH key fingerprint: SHA256:jD3oMT4WL3WHPJQbrjC3l5feNCnkv7ndW8nYaHX5wFw
14 changed files with 2617 additions and 264 deletions

2283
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -17,9 +17,17 @@ blog = [
"dep:reqwest",
"dep:ipld-core",
"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]
actix-web = "4.11.0"
anyhow = "1.0.90"
@ -42,11 +50,13 @@ clap = { version = "4.5.20", features = [
color-eyre = "0.6.3"
config = "0.15.14"
crossterm = { version = "0.28.1", features = ["serde", "event-stream"] }
default_variant = "0.1.0"
derive_deref = "1.1.1"
directories = "5.0.1"
figlet-rs = "0.1.5"
futures = "0.3.31"
human-panic = "2.0.2"
image = { version = "0.25.6", optional = true }
indoc = "2.0.5"
ipld-core = { version = "0.4.2", optional = true }
json5 = "0.4.1"
@ -55,6 +65,7 @@ libc = "0.2.161"
mime_guess = "2.0.5"
pretty_assertions = "1.4.1"
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 }
russh = "0.49.2"
rust-embed = { version = "8.7.2", features = ["actix"] }
@ -73,5 +84,6 @@ tui-markdown = { version = "0.3.5", optional = true }
[build-dependencies]
anyhow = "1.0.90"
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"] }
vergen-gix = { version = "1.0.2", features = ["build", "cargo"] }

View file

@ -18,6 +18,10 @@ const SSH_KEY_ALGOS: &[(&str, Algorithm)] = &[
fn main() -> Result<()> {
println!("cargo:rerun-if-changed=build.rs");
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
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());

View file

@ -0,0 +1,21 @@
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 +1,4 @@
nightly-2025-07-30
[toolchain]
channel = "stable"
version = "1.87"
components = ["clippy", "rust-analyzer", "cargo", "rustc"]

View file

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

View file

@ -1,5 +1,6 @@
use std::sync::atomic::AtomicUsize;
use std::sync::Arc;
use std::time::Duration;
use color_eyre::{eyre, Result};
use crossterm::event::{KeyCode, KeyEvent};
@ -17,12 +18,14 @@ use crate::action::Action;
use crate::components::*;
use crate::config::Config;
use crate::keycode::KeyCodeExt;
use crate::tui::terminal::{TerminalInfo, TerminalKind, UnsupportedReason};
use crate::tui::{Event, Terminal, Tui};
pub struct App {
config: Config,
tick_rate: f64,
frame_rate: f64,
terminal_info: Arc<RwLock<TerminalInfo>>,
should_quit: bool,
should_suspend: bool,
@ -44,9 +47,7 @@ pub struct App {
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 {
#[default]
Home,
@ -56,6 +57,7 @@ impl App {
pub const MIN_TUI_DIMS: (u16, u16) = (105, 25);
pub fn new(
terminal_info: Arc<RwLock<TerminalInfo>>,
tick_rate: f64,
frame_rate: f64,
keystroke_rx: mpsc::UnboundedReceiver<Vec<u8>>,
@ -81,6 +83,7 @@ impl App {
)));
Ok(Self {
terminal_info,
tick_rate,
frame_rate,
should_quit: false,
@ -111,9 +114,7 @@ impl App {
) -> Result<()> {
let mut tui = tui.write().await;
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
@ -125,43 +126,33 @@ impl App {
tui.enter()?;
// Register action handlers
self.tabs
.try_lock()?
.register_action_handler(self.action_tx.clone())?;
self.content
.try_lock()?
.register_action_handler(self.action_tx.clone())?;
self.cat
.try_lock()?
.register_action_handler(self.action_tx.clone())?;
self.tabs.try_lock()?.register_action_handler(self.action_tx.clone())?;
self.content.try_lock()?.register_action_handler(self.action_tx.clone())?;
self.cat.try_lock()?.register_action_handler(self.action_tx.clone())?;
#[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
self.tabs
.try_lock()?
.register_config_handler(self.config.clone())?;
self.content
.try_lock()?
.register_config_handler(self.config.clone())?;
self.cat
.try_lock()?
.register_config_handler(self.config.clone())?;
self.tabs.try_lock()?.register_config_handler(self.config.clone())?;
self.content.try_lock()?.register_config_handler(self.config.clone())?;
self.cat.try_lock()?.register_config_handler(self.config.clone())?;
#[cfg(feature = "blog")]
self.blog_posts
.try_lock()?
.register_config_handler(self.config.clone())?;
self.blog_posts.try_lock()?.register_config_handler(self.config.clone())?;
while let TerminalKind::Unsupported(UnsupportedReason::Unprobed) =
self.terminal_info.blocking_read().kind()
{
tracing::debug!("Waiting for terminal kind to be probed...");
std::thread::sleep(Duration::from_millis(100));
}
// Initialize components
let size = tui.terminal.try_lock()?.size()?;
self.tabs.try_lock()?.init(size)?;
self.content.try_lock()?.init(size)?;
self.tabs.try_lock()?.init(self.terminal_info.clone(), size)?;
self.content.try_lock()?.init(self.terminal_info.clone(), size)?;
self.cat.try_lock()?.init(self.terminal_info.clone(), size)?;
#[cfg(feature = "blog")]
self.cat.try_lock()?.init(size)?;
self.blog_posts.try_lock()?.init(size)?;
self.blog_posts.try_lock()?.init(self.terminal_info.clone(), size)?;
Ok::<_, eyre::Error>(())
})?;
@ -267,8 +258,7 @@ impl App {
Action::Resume => self.should_suspend = false,
Action::ClearScreen => tui.terminal.try_lock()?.clear()?,
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)?;
}
Action::Render => self.render(tui)?,
@ -276,14 +266,10 @@ impl App {
}
// 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)?;
}
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)?;
}
if let Some(action) = self.cat.try_lock()?.update(action.clone())? {
@ -291,9 +277,7 @@ impl App {
}
#[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)?;
}
}
@ -345,8 +329,9 @@ impl App {
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)),
Line::from(error_message.clone()).style(
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
),
)
.alignment(Alignment::Center)
.wrap(Wrap { trim: false }),
@ -360,9 +345,7 @@ impl App {
term.try_draw(|frame| {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(
[Constraint::Length(3), Constraint::Min(0)].as_ref(),
)
.constraints([Constraint::Length(3), Constraint::Min(0)].as_ref())
.split(frame.area());
// Render the domain name text
@ -373,17 +356,11 @@ impl App {
frame.render_widget(
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
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(
frame,
@ -442,11 +419,7 @@ impl App {
"Blog feature is disabled. Enable the `blog` feature \
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);
}

View file

@ -1,11 +1,15 @@
use std::sync::Arc;
use color_eyre::Result;
use crossterm::event::{KeyEvent, MouseEvent};
use ratatui::layout::{Rect, Size};
use ratatui::Frame;
use tokio::sync::mpsc::UnboundedSender;
use tokio::sync::RwLock;
use crate::action::Action;
use crate::config::Config;
use crate::tui::terminal::TerminalInfo;
use crate::tui::Event;
//
@ -61,7 +65,7 @@ pub trait Component: Send {
let _ = config; // to appease clippy
Ok(())
}
/// Initialize the component with a specified area if necessary.
/// Initialize the component with a specified area and terminal kind if necessary.
///
/// # Arguments
///
@ -70,8 +74,8 @@ pub trait Component: Send {
/// # Returns
///
/// * `Result<()>` - An Ok result or an error.
fn init(&mut self, area: Size) -> Result<()> {
let _ = area; // to appease clippy
fn init(&mut self, term_info: Arc<RwLock<TerminalInfo>>, area: Size) -> Result<()> {
let _ = (area, term_info); // to appease clippy
Ok(())
}
/// Handle incoming events and produce actions if necessary.

View file

@ -1,19 +1,32 @@
use std::io::{BufReader, Cursor};
use std::sync::Arc;
use color_eyre::eyre::eyre;
use color_eyre::owo_colors::OwoColorize;
use color_eyre::Result;
use ratatui::widgets::Widget;
use image::{ImageReader, Rgba};
use ratatui::layout::{Constraint, Flex, Layout, Rect, Size};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{self, Line, Span, Text};
use ratatui::widgets::{Block, Paragraph, Widget as _, Wrap};
use ratatui_image::picker::{Picker, ProtocolType};
use ratatui_image::protocol::StatefulProtocol;
use ratatui_image::{FilterType, FontSize, 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>;
#[derive(Debug)]
pub struct BlogPosts {
list: SelectionList<Post>,
posts: Vec<Post>,
in_post: Option<usize>,
image_renderer: Option<Picker>,
in_post: (Option<StatefulProtocol>, Option<usize>),
}
impl BlogPosts {
@ -21,28 +34,73 @@ impl BlogPosts {
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,
in_post: (None, None),
}
}
pub fn is_in_post(&self) -> bool {
self.in_post.is_some()
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 register_config_handler(
&mut self,
config: crate::config::Config,
) -> Result<()> {
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<()> {
fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> Result<()> {
self.list.register_action_handler(tx)
}
@ -52,10 +110,9 @@ impl Component for BlogPosts {
Action::Tick => {}
Action::Render => {}
Action::Quit | Action::PrevTab | Action::NextTab => {
self.in_post = None
}
Action::Continue(post_id) => self.in_post = post_id,
// 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,
_ => {}
};
@ -67,16 +124,70 @@ impl Component for BlogPosts {
frame: &mut ratatui::Frame,
area: ratatui::prelude::Rect,
) -> Result<()> {
if let Some(post_id_inner) = self.in_post {
let post_body = self
if let Some(post_id_inner) = self.in_post.1 {
let post = self
.posts
.get(post_id_inner)
.map_or(String::from("404 - Blog not found!"), |post| {
post.content.clone()
});
.ok_or(eyre!("Current post apparently doesn't exist"))?;
let post_widget = tui_markdown::from_str(&post_body);
post_widget.render(area, frame.buffer_mut());
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)?;
}

View file

@ -14,6 +14,12 @@ use crate::action::Action;
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)]
pub struct Content {
command_tx: Option<UnboundedSender<Action>>,

View file

@ -9,12 +9,6 @@ use crate::action::Action;
use crate::components::{Component, Post};
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)]
pub struct SelectionList<T> {
config: Config,
@ -106,7 +100,7 @@ impl Component for SelectionList<Post> {
];
let subtitle_span = Span::raw(
[" ", post.subtitle.as_ref().unwrap_or(&truncate(post.content.as_ref(), 40))]
[" ", post.subtitle.as_ref().unwrap_or(&super::truncate(post.content.as_ref(), 40))]
.concat(),
);

View file

@ -13,6 +13,7 @@ use tracing::instrument;
use crate::app::App;
use crate::tui::backend::SshBackend;
use crate::tui::terminal::{TerminalInfo, TerminalKind, UnsupportedReason};
use crate::tui::{Terminal, Tui};
use crate::OPTIONS;
@ -50,10 +51,7 @@ impl TermWriter {
impl Write for TermWriter {
#[instrument(skip(self, buf), level = "debug")]
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);
Ok(buf.len())
}
@ -67,6 +65,7 @@ impl Write for TermWriter {
#[allow(clippy::type_complexity)]
pub struct SshSession {
terminal_info: Arc<RwLock<TerminalInfo>>,
app: Option<Arc<Mutex<App>>>,
keystroke_tx: mpsc::UnboundedSender<Vec<u8>>,
resize_tx: mpsc::UnboundedSender<(u16, u16)>,
@ -81,8 +80,12 @@ impl SshSession {
let (resize_tx, resize_rx) = mpsc::unbounded_channel();
let (init_dims_tx, init_dims_rx) = oneshot::channel();
let term_info = Arc::new(RwLock::new(TerminalInfo::default()));
Self {
terminal_info: Arc::clone(&term_info),
app: App::new(
term_info,
OPTIONS.tick_rate,
OPTIONS.frame_rate,
keystroke_rx,
@ -101,16 +104,13 @@ impl SshSession {
async fn run_app(
app: Arc<Mutex<App>>,
writer: Arc<Mutex<Terminal>>,
term: Arc<Mutex<Terminal>>,
tui: Arc<RwLock<Option<Tui>>>,
session: &Handle,
channel_id: ChannelId,
) -> eyre::Result<()> {
app.lock_owned().await.run(writer, tui).await?;
session
.close(channel_id)
.await
.map_err(|_| eyre!("failed to close session"))?;
app.lock_owned().await.run(term, tui).await?;
session.close(channel_id).await.map_err(|_| eyre!("failed to close session"))?;
session
.exit_status_request(channel_id, 0)
.await
@ -143,26 +143,28 @@ impl Handler for SshSession {
tracing::info!("Serving app to open session");
tokio::task::spawn(async move {
let result = async || -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let ((term_width, term_height), (pixel_width, pixel_height)) = rx.await?;
let writer = Arc::new(Mutex::new(Terminal::new(SshBackend::new(
TermWriter::new(session_handle.clone(), channel),
term_width,
term_height,
pixel_width,
pixel_height,
))?));
let result =
async || -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let ((term_width, term_height), (pixel_width, pixel_height)) =
rx.await?;
let writer = Arc::new(Mutex::new(Terminal::new(SshBackend::new(
TermWriter::new(session_handle.clone(), channel),
term_width,
term_height,
pixel_width,
pixel_height,
))?));
Self::run_app(inner_app, writer, tui, &session_handle, channel_id).await?;
Ok(())
};
Self::run_app(inner_app, writer, tui, &session_handle, channel_id)
.await?;
Ok(())
};
match result().await {
Ok(()) => tracing::info!("Session exited successfully"),
Err(err) => {
tracing::error!("Session errored: {err}");
let _ =
session_handle.channel_failure(channel_id).await;
let _ = session_handle.channel_failure(channel_id).await;
}
}
});
@ -173,6 +175,31 @@ impl Handler for SshSession {
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))]
async fn pty_request(
&mut self,
@ -191,6 +218,18 @@ impl Handler for SshSession {
{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") {
session.channel_failure(channel_id)?;
return Err(eyre!("Unsupported terminal type: {term}"));
@ -218,10 +257,7 @@ impl Handler for SshSession {
data: &[u8],
_session: &mut Session,
) -> Result<(), Self::Error> {
tracing::debug!(
"Received keystroke data from SSH: {:?}, sending",
data
);
tracing::debug!("Received keystroke data from SSH: {:?}, sending", data);
self.keystroke_tx
.send(data.to_vec())
.map_err(|_| eyre!("Failed to send event keystroke data"))
@ -232,12 +268,15 @@ impl Handler for SshSession {
_: ChannelId,
col_width: u32,
row_height: u32,
_: u32,
_: u32,
pix_width: u32,
pix_height: u32,
_: &mut Session,
) -> Result<(), Self::Error> {
// TODO: actually make it resize properly
// That would involve first updating the Backend's size and then updating the rect via the event
self.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")))?,
));
self.resize_tx
.send((col_width as u16, row_height as u16))
.map_err(|_| eyre!("Failed to send pty size specifications"))?;
@ -254,9 +293,7 @@ impl SshServer {
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))
Self.run_on_socket(Arc::new(config), &listener).await.map_err(|err| eyre!(err))
}
}

View file

@ -23,6 +23,7 @@ use tracing::error;
pub(crate) mod backend;
pub(crate) mod status;
pub(crate) mod terminal;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum Event {

155
src/tui/terminal.rs Normal file
View file

@ -0,0 +1,155 @@
use std::default::Default;
use default_variant::default;
use ratatui_image::{
picker::{Capability, ProtocolType},
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
}
}