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:
parent
b878008c25
commit
ee524dc160
14 changed files with 2617 additions and 264 deletions
2283
Cargo.lock
generated
2283
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
14
Cargo.toml
14
Cargo.toml
|
@ -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"] }
|
||||
|
|
4
build.rs
4
build.rs
|
@ -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());
|
||||
|
|
21
patches/ratatui-image+8.0.1.patch
Normal file
21
patches/ratatui-image+8.0.1.patch
Normal 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].
|
|
@ -1 +1,4 @@
|
|||
nightly-2025-07-30
|
||||
[toolchain]
|
||||
channel = "stable"
|
||||
version = "1.87"
|
||||
components = ["clippy", "rust-analyzer", "cargo", "rustc"]
|
||||
|
|
|
@ -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"
|
||||
|
|
101
src/app.rs
101
src/app.rs
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)?;
|
||||
}
|
||||
|
|
|
@ -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>>,
|
||||
|
|
|
@ -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(),
|
||||
);
|
||||
|
||||
|
|
89
src/ssh.rs
89
src/ssh.rs
|
@ -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,8 +143,10 @@ 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 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,
|
||||
|
@ -153,7 +155,8 @@ impl Handler for SshSession {
|
|||
pixel_height,
|
||||
))?));
|
||||
|
||||
Self::run_app(inner_app, writer, tui, &session_handle, channel_id).await?;
|
||||
Self::run_app(inner_app, writer, tui, &session_handle, channel_id)
|
||||
.await?;
|
||||
Ok(())
|
||||
};
|
||||
|
||||
|
@ -161,8 +164,7 @@ impl Handler for SshSession {
|
|||
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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
155
src/tui/terminal.rs
Normal 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
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue