Compare commits
No commits in common. "main" and "main" have entirely different histories.
25 changed files with 576 additions and 2760 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,17 +17,9 @@ blog = [
|
|||
"dep:reqwest",
|
||||
"dep:ipld-core",
|
||||
"dep:tui-markdown",
|
||||
"dep:chrono",
|
||||
"dep:ratatui-image",
|
||||
"dep:image"
|
||||
"dep:chrono"
|
||||
]
|
||||
|
||||
[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"
|
||||
|
@ -50,13 +42,11 @@ 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"
|
||||
|
@ -65,7 +55,6 @@ 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"] }
|
||||
|
@ -84,6 +73,5 @@ 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"] }
|
||||
|
|
8
build.rs
8
build.rs
|
@ -18,10 +18,6 @@ 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());
|
||||
|
@ -36,8 +32,8 @@ fn main() -> Result<()> {
|
|||
continue;
|
||||
}
|
||||
|
||||
let key =
|
||||
PrivateKey::random(&mut rng, algo.to_owned()).map_err(anyhow::Error::from)?;
|
||||
let key = PrivateKey::random(&mut rng, algo.to_owned())
|
||||
.map_err(anyhow::Error::from)?;
|
||||
key.write_openssh_file(&path, LineEnding::default())?;
|
||||
}
|
||||
|
||||
|
|
|
@ -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].
|
|
@ -1,3 +1 @@
|
|||
[toolchain]
|
||||
channel = "nightly-2025-03-28"
|
||||
components = ["clippy", "rust-analyzer", "cargo", "rustc"]
|
||||
nightly-2025-07-30
|
|
@ -1,6 +1,6 @@
|
|||
edition = "2021"
|
||||
use_small_heuristics = "Max"
|
||||
max_width = 95
|
||||
max_width = 80
|
||||
newline_style = "Unix"
|
||||
|
||||
indent_style = "Block"
|
||||
|
@ -8,6 +8,5 @@ hard_tabs = false
|
|||
|
||||
format_strings = true
|
||||
brace_style = "PreferSameLine"
|
||||
chain_width = 95
|
||||
|
||||
imports_granularity = "Module"
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
use std::fmt;
|
||||
|
||||
use serde::de::{self, Visitor};
|
||||
use serde::{Deserialize, Deserializer, Serialize};
|
||||
use serde::{de::{self, Visitor}, Deserialize, Deserializer, Serialize};
|
||||
use strum::Display;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Display, Serialize)]
|
||||
|
@ -31,8 +30,8 @@ pub enum Action {
|
|||
|
||||
impl<'de> Deserialize<'de> for Action {
|
||||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>, {
|
||||
where D: Deserializer<'de>
|
||||
{
|
||||
struct 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>
|
||||
where
|
||||
E: de::Error, {
|
||||
where E: de::Error
|
||||
{
|
||||
if v == "Continue" {
|
||||
Ok(Action::Continue(None))
|
||||
} else {
|
||||
|
|
105
src/app.rs
105
src/app.rs
|
@ -1,6 +1,5 @@
|
|||
use std::sync::atomic::AtomicUsize;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use color_eyre::{eyre, Result};
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
|
@ -18,14 +17,12 @@ 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,
|
||||
|
@ -47,7 +44,9 @@ 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,
|
||||
|
@ -57,7 +56,6 @@ 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>>,
|
||||
|
@ -83,7 +81,6 @@ impl App {
|
|||
)));
|
||||
|
||||
Ok(Self {
|
||||
terminal_info,
|
||||
tick_rate,
|
||||
frame_rate,
|
||||
should_quit: false,
|
||||
|
@ -114,7 +111,9 @@ 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
|
||||
|
@ -126,33 +125,43 @@ 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())?;
|
||||
|
||||
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));
|
||||
}
|
||||
self.blog_posts
|
||||
.try_lock()?
|
||||
.register_config_handler(self.config.clone())?;
|
||||
|
||||
// Initialize components
|
||||
let size = tui.terminal.try_lock()?.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)?;
|
||||
self.tabs.try_lock()?.init(size)?;
|
||||
self.content.try_lock()?.init(size)?;
|
||||
#[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>(())
|
||||
})?;
|
||||
|
@ -258,7 +267,8 @@ 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)?,
|
||||
|
@ -266,10 +276,14 @@ 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())? {
|
||||
|
@ -277,7 +291,9 @@ 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)?;
|
||||
}
|
||||
}
|
||||
|
@ -329,9 +345,8 @@ 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 }),
|
||||
|
@ -345,7 +360,9 @@ 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
|
||||
|
@ -356,11 +373,17 @@ 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,
|
||||
|
@ -416,10 +439,14 @@ impl App {
|
|||
// If blog feature is not enabled, render a placeholder
|
||||
content_rect.height = 1;
|
||||
let placeholder = Paragraph::new(
|
||||
"Blog feature is disabled. Enable the `blog` feature to view this \
|
||||
tab.",
|
||||
"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);
|
||||
}
|
||||
|
|
|
@ -28,20 +28,30 @@ pub mod blog {
|
|||
lazy_static! {
|
||||
static ref POSTS_CACHE_STORE: MemoryStore<usize, (Instant, com::whtwnd::blog::entry::Record)> =
|
||||
MemoryStore::default();
|
||||
static ref AGENT: Agent<CredentialSession<MemoryStore<(), Object<SessionOutputData>>, ReqwestClient>> =
|
||||
Agent::new(CredentialSession::new(
|
||||
static ref AGENT: Agent<
|
||||
CredentialSession<
|
||||
MemoryStore<(), Object<SessionOutputData>>,
|
||||
ReqwestClient,
|
||||
>,
|
||||
> = Agent::new(CredentialSession::new(
|
||||
ReqwestClient::new("https://bsky.social"),
|
||||
MemorySessionStore::default(),
|
||||
));
|
||||
}
|
||||
|
||||
#[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 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 {
|
||||
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?;
|
||||
return fetch_posts_into_cache().await;
|
||||
}
|
||||
|
@ -52,7 +62,9 @@ pub mod blog {
|
|||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
@ -60,7 +72,8 @@ pub mod blog {
|
|||
}
|
||||
|
||||
#[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
|
||||
.api
|
||||
.com
|
||||
|
@ -87,7 +100,9 @@ pub mod blog {
|
|||
.map(|elem| {
|
||||
if let Unknown::Object(btree_map) = &elem.data.value {
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -1,15 +1,11 @@
|
|||
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;
|
||||
|
||||
//
|
||||
|
@ -45,7 +41,10 @@ pub trait Component: Send {
|
|||
/// # Returns
|
||||
///
|
||||
/// * `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
|
||||
Ok(())
|
||||
}
|
||||
|
@ -62,7 +61,7 @@ pub trait Component: Send {
|
|||
let _ = config; // to appease clippy
|
||||
Ok(())
|
||||
}
|
||||
/// Initialize the component with a specified area and terminal kind if necessary.
|
||||
/// Initialize the component with a specified area if necessary.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
|
@ -71,8 +70,8 @@ pub trait Component: Send {
|
|||
/// # Returns
|
||||
///
|
||||
/// * `Result<()>` - An Ok result or an error.
|
||||
fn init(&mut self, term_info: Arc<RwLock<TerminalInfo>>, area: Size) -> Result<()> {
|
||||
let _ = (area, term_info); // to appease clippy
|
||||
fn init(&mut self, area: Size) -> Result<()> {
|
||||
let _ = area; // to appease clippy
|
||||
Ok(())
|
||||
}
|
||||
/// Handle incoming events and produce actions if necessary.
|
||||
|
@ -84,10 +83,15 @@ pub trait Component: Send {
|
|||
/// # Returns
|
||||
///
|
||||
/// * `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 {
|
||||
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,
|
||||
};
|
||||
Ok(action)
|
||||
|
@ -114,7 +118,10 @@ pub trait Component: Send {
|
|||
/// # Returns
|
||||
///
|
||||
/// * `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
|
||||
Ok(None)
|
||||
}
|
||||
|
|
|
@ -1,30 +1,19 @@
|
|||
use std::io::{BufReader, Cursor};
|
||||
use std::sync::Arc;
|
||||
|
||||
use color_eyre::eyre::eyre;
|
||||
use color_eyre::Result;
|
||||
use image::{ImageReader, Rgba};
|
||||
use ratatui::layout::{Constraint, Flex, Layout, Rect, Size};
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::widgets::*;
|
||||
use ratatui_image::picker::{Picker, ProtocolType};
|
||||
use ratatui_image::protocol::StatefulProtocol;
|
||||
use ratatui_image::{Resize, StatefulImage};
|
||||
use ratatui::widgets::Widget;
|
||||
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>,
|
||||
image_renderer: Option<Picker>,
|
||||
in_post: (Option<StatefulProtocol>, Option<usize>),
|
||||
in_post: Option<usize>,
|
||||
}
|
||||
|
||||
impl BlogPosts {
|
||||
|
@ -32,73 +21,28 @@ 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, None),
|
||||
in_post: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_in_post(&self) -> bool {
|
||||
self.in_post.1.is_some()
|
||||
}
|
||||
|
||||
async fn header_image(&self, img: Ogp) -> Result<StatefulProtocol> {
|
||||
if let Some(picker) = &self.image_renderer {
|
||||
let img_blob = reqwest::get(img.url)
|
||||
.await?
|
||||
.bytes()
|
||||
.await?
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect::<Vec<u8>>();
|
||||
|
||||
let dyn_img = ImageReader::new(BufReader::new(Cursor::new(img_blob)))
|
||||
.with_guessed_format()?
|
||||
.decode()?;
|
||||
let sized_img = picker.new_resize_protocol(dyn_img);
|
||||
|
||||
return Ok(sized_img);
|
||||
}
|
||||
|
||||
Err(eyre!("No image supported renderer initialized"))
|
||||
self.in_post.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
impl Component for BlogPosts {
|
||||
fn init(&mut self, term_info: Arc<RwLock<TerminalInfo>>, _: Size) -> Result<()> {
|
||||
let locked_info = term_info.blocking_read().clone();
|
||||
|
||||
if matches!(locked_info.kind(), TerminalKind::Unsupported(UnsupportedReason::Unsized))
|
||||
{
|
||||
self.image_renderer = None;
|
||||
}
|
||||
|
||||
if let Some(picker) = &mut self.image_renderer {
|
||||
picker.capabilities = locked_info.kind().capabilities();
|
||||
picker.protocol_type = locked_info.kind().as_protocol();
|
||||
picker.font_size = locked_info.font_size();
|
||||
|
||||
tracing::info!(
|
||||
"Using {:?} rendering protocol for blog image renderer, font size: {:?}",
|
||||
picker.protocol_type(),
|
||||
picker.font_size(),
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn register_config_handler(&mut self, config: crate::config::Config) -> Result<()> {
|
||||
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)
|
||||
}
|
||||
|
||||
|
@ -108,9 +52,10 @@ impl Component for BlogPosts {
|
|||
Action::Tick => {}
|
||||
Action::Render => {}
|
||||
|
||||
// FIXME: do we reload the image on every single render of a post?
|
||||
Action::Quit | Action::PrevTab | Action::NextTab => self.in_post = (None, None),
|
||||
Action::Continue(post_id) => self.in_post.1 = post_id,
|
||||
Action::Quit | Action::PrevTab | Action::NextTab => {
|
||||
self.in_post = None
|
||||
}
|
||||
Action::Continue(post_id) => self.in_post = post_id,
|
||||
_ => {}
|
||||
};
|
||||
|
||||
|
@ -122,70 +67,16 @@ impl Component for BlogPosts {
|
|||
frame: &mut ratatui::Frame,
|
||||
area: ratatui::prelude::Rect,
|
||||
) -> Result<()> {
|
||||
if let Some(post_id_inner) = self.in_post.1 {
|
||||
let post = self
|
||||
if let Some(post_id_inner) = self.in_post {
|
||||
let post_body = self
|
||||
.posts
|
||||
.get(post_id_inner)
|
||||
.ok_or(eyre!("Current post apparently doesn't exist"))?;
|
||||
|
||||
let post_body = post.title.clone().map_or(post.content.clone(), |title| {
|
||||
format!("# {}\n\n{}", title, post.content)
|
||||
.map_or(String::from("404 - Blog not found!"), |post| {
|
||||
post.content.clone()
|
||||
});
|
||||
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),
|
||||
);
|
||||
}
|
||||
let post_widget = tui_markdown::from_str(&post_body);
|
||||
post_widget.render(area, frame.buffer_mut());
|
||||
} else {
|
||||
self.list.draw(frame, area)?;
|
||||
}
|
||||
|
|
|
@ -28,7 +28,10 @@ impl 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);
|
||||
Ok(())
|
||||
}
|
||||
|
@ -54,7 +57,12 @@ impl Component for Cat {
|
|||
.fg(Color::Magenta)
|
||||
.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(())
|
||||
|
|
|
@ -14,12 +14,6 @@ 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>>,
|
||||
|
@ -41,8 +35,11 @@ impl Content {
|
|||
.ok_or(eyre!("Failed to create figlet header for about page"))?
|
||||
.to_string();
|
||||
|
||||
let lines: Vec<String> =
|
||||
greetings_header.trim_end_matches('\n').split('\n').map(String::from).collect();
|
||||
let lines: Vec<String> = greetings_header
|
||||
.trim_end_matches('\n')
|
||||
.split('\n')
|
||||
.map(String::from)
|
||||
.collect();
|
||||
|
||||
let mut content = lines
|
||||
.iter()
|
||||
|
@ -57,7 +54,10 @@ impl Content {
|
|||
"she/they",
|
||||
Style::default().add_modifier(Modifier::ITALIC),
|
||||
),
|
||||
Span::from("), and I make scalable systems or something. IDFK."),
|
||||
Span::from(
|
||||
"), and I make scalable systems or something. \
|
||||
IDFK.",
|
||||
),
|
||||
]);
|
||||
}
|
||||
Line::raw(format!(" {}", line))
|
||||
|
@ -69,7 +69,9 @@ impl Content {
|
|||
Line::from(""),
|
||||
Line::from(vec![
|
||||
Span::from(" "),
|
||||
Span::from("I specialize in systems programming, primarily in "),
|
||||
Span::from(
|
||||
"I specialize in systems programming, primarily in ",
|
||||
),
|
||||
Span::styled(
|
||||
"Rust 🦀",
|
||||
Style::default()
|
||||
|
@ -87,42 +89,51 @@ impl Content {
|
|||
]),
|
||||
Line::from(""),
|
||||
Line::from(
|
||||
" I am an avid believer of open-source software, and contribute to a few \
|
||||
projects such as:",
|
||||
" I am an avid believer of open-source software, and \
|
||||
contribute to a few projects such as:",
|
||||
),
|
||||
]);
|
||||
|
||||
let projects = vec![
|
||||
(
|
||||
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",
|
||||
),
|
||||
(
|
||||
Style::default().fg(Color::Blue).add_modifier(Modifier::BOLD),
|
||||
"DiscordLuau/discord-luau: A Luau library for creating Discord bots, powered \
|
||||
by Lune",
|
||||
"DiscordLuau/discord-luau: A Luau library for creating \
|
||||
Discord bots, powered by Lune",
|
||||
),
|
||||
(
|
||||
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
|
||||
"pesde-pkg/pesde: A package manager for the Luau programming language, \
|
||||
supporting multiple runtimes including Roblox and Lune",
|
||||
"pesde-pkg/pesde: A package manager for the Luau programming \
|
||||
language, supporting multiple runtimes including Roblox and \
|
||||
Lune",
|
||||
),
|
||||
];
|
||||
|
||||
for (style, project) in projects {
|
||||
let parts: Vec<&str> = project.splitn(2, ':').collect();
|
||||
let (left, right) =
|
||||
if parts.len() == 2 { (parts[0], parts[1]) } else { (project, "") };
|
||||
let (left, right) = if parts.len() == 2 {
|
||||
(parts[0], parts[1])
|
||||
} else {
|
||||
(project, "")
|
||||
};
|
||||
|
||||
let formatted_left = Span::styled(left, style);
|
||||
|
||||
let bullet = " • ";
|
||||
let indent = " ";
|
||||
|
||||
let first_line = if project.len() > area.width as usize - bullet.len() {
|
||||
let first_line =
|
||||
if project.len() > area.width as usize - bullet.len() {
|
||||
let split_point = project
|
||||
.char_indices()
|
||||
.take_while(|(i, _)| *i < area.width as usize - bullet.len())
|
||||
.take_while(|(i, _)| {
|
||||
*i < area.width as usize - bullet.len()
|
||||
})
|
||||
.last()
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(project.len());
|
||||
|
@ -132,7 +143,9 @@ impl Content {
|
|||
formatted_left,
|
||||
Span::from(":"),
|
||||
Span::styled(
|
||||
first.trim_start_matches(format!("{left}:").as_str()).to_string(),
|
||||
first
|
||||
.trim_start_matches(format!("{left}:").as_str())
|
||||
.to_string(),
|
||||
Style::default().fg(Color::White),
|
||||
),
|
||||
]));
|
||||
|
@ -142,7 +155,10 @@ impl Content {
|
|||
Span::from(bullet),
|
||||
formatted_left,
|
||||
Span::from(":"),
|
||||
Span::styled(right.to_string(), Style::default().fg(Color::White)),
|
||||
Span::styled(
|
||||
right.to_string(),
|
||||
Style::default().fg(Color::White),
|
||||
),
|
||||
]));
|
||||
String::new()
|
||||
};
|
||||
|
@ -152,14 +168,19 @@ impl Content {
|
|||
if remaining_text.len() > area.width as usize - indent.len() {
|
||||
let split_point = remaining_text
|
||||
.char_indices()
|
||||
.take_while(|(i, _)| *i < area.width as usize - indent.len())
|
||||
.take_while(|(i, _)| {
|
||||
*i < area.width as usize - indent.len()
|
||||
})
|
||||
.last()
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(remaining_text.len());
|
||||
let (first, rest) = remaining_text.split_at(split_point);
|
||||
content.push(Line::from(vec![
|
||||
Span::from(indent),
|
||||
Span::styled(first.to_string(), Style::default().fg(Color::White)),
|
||||
Span::styled(
|
||||
first.to_string(),
|
||||
Style::default().fg(Color::White),
|
||||
),
|
||||
]));
|
||||
remaining_text = rest.to_string();
|
||||
} else {
|
||||
|
@ -178,8 +199,8 @@ impl Content {
|
|||
content.extend(vec![
|
||||
Line::from(""),
|
||||
Line::from(
|
||||
" I am also a fan of the 8 bit aesthetic and think seals are super adorable \
|
||||
:3",
|
||||
" I am also a fan of the 8 bit aesthetic and think seals are \
|
||||
super adorable :3",
|
||||
),
|
||||
]);
|
||||
|
||||
|
@ -203,7 +224,10 @@ impl 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);
|
||||
Ok(())
|
||||
}
|
||||
|
@ -233,7 +257,9 @@ impl Component for Content {
|
|||
|
||||
// Create the border lines
|
||||
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;
|
||||
border_top.spans.push(Span::styled(
|
||||
|
@ -245,8 +271,14 @@ impl Component for Content {
|
|||
let mut current_pos = 1 + devcomp_width;
|
||||
|
||||
for (i, &tab) in tabs.iter().enumerate() {
|
||||
let (char, style) = if i == self.selected_tab.load(Ordering::Relaxed) {
|
||||
("━", Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD))
|
||||
let (char, style) =
|
||||
if i == self.selected_tab.load(Ordering::Relaxed) {
|
||||
(
|
||||
"━",
|
||||
Style::default()
|
||||
.fg(Color::Magenta)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
} else {
|
||||
("─", Style::default().fg(Color::DarkGray))
|
||||
};
|
||||
|
@ -267,15 +299,19 @@ impl Component for Content {
|
|||
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(
|
||||
"╰".to_owned() + &"─".repeat(area.width as usize - 2) + "╯",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
|
||||
let border_left = Span::styled("│", Style::default().fg(Color::DarkGray));
|
||||
let border_right = Span::styled("│", Style::default().fg(Color::DarkGray));
|
||||
let border_left =
|
||||
Span::styled("│", Style::default().fg(Color::DarkGray));
|
||||
let border_right =
|
||||
Span::styled("│", Style::default().fg(Color::DarkGray));
|
||||
|
||||
// Render the content
|
||||
let content_widget = Paragraph::new(content)
|
||||
|
@ -300,7 +336,12 @@ impl Component for Content {
|
|||
|
||||
frame.render_widget(
|
||||
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 {
|
||||
|
@ -311,7 +352,12 @@ impl Component for Content {
|
|||
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(border_right.clone())),
|
||||
Rect { x: area.x + area.width - 1, y: area.y + i, width: 1, height: 1 },
|
||||
Rect {
|
||||
x: area.x + area.width - 1,
|
||||
y: area.y + i,
|
||||
width: 1,
|
||||
height: 1,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,13 +1,20 @@
|
|||
use chrono::DateTime;
|
||||
use color_eyre::eyre::Result;
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::widgets::*;
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use ratatui::widgets::{Block, Borders, List, ListItem, ListState};
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
|
||||
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,
|
||||
|
@ -99,12 +106,7 @@ impl Component for SelectionList<Post> {
|
|||
];
|
||||
|
||||
let subtitle_span = Span::raw(
|
||||
[
|
||||
" ",
|
||||
post.subtitle
|
||||
.as_ref()
|
||||
.unwrap_or(&super::truncate(post.content.as_ref(), 40)),
|
||||
]
|
||||
[" ", post.subtitle.as_ref().unwrap_or(&truncate(post.content.as_ref(), 40))]
|
||||
.concat(),
|
||||
);
|
||||
|
||||
|
|
|
@ -19,7 +19,10 @@ pub struct 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() }
|
||||
}
|
||||
|
||||
|
@ -41,7 +44,10 @@ impl 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);
|
||||
Ok(())
|
||||
}
|
||||
|
@ -78,13 +84,17 @@ impl Component for Tabs {
|
|||
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("│", Style::default().fg(Color::DarkGray)));
|
||||
tab_lines[1]
|
||||
.spans
|
||||
.push(Span::styled("│", Style::default().fg(Color::DarkGray)));
|
||||
}
|
||||
|
||||
let tabs_widget =
|
||||
Paragraph::new(tab_lines).block(Block::default().borders(Borders::NONE));
|
||||
let tabs_widget = Paragraph::new(tab_lines)
|
||||
.block(Block::default().borders(Borders::NONE));
|
||||
|
||||
frame.render_widget(
|
||||
tabs_widget,
|
||||
|
|
|
@ -38,11 +38,16 @@ pub struct Config {
|
|||
}
|
||||
|
||||
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> =
|
||||
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> =
|
||||
env::var(format!("{}_CONFIG", PROJECT_NAME.clone())).ok().map(PathBuf::from);
|
||||
env::var(format!("{}_CONFIG", PROJECT_NAME.clone()))
|
||||
.ok()
|
||||
.map(PathBuf::from);
|
||||
}
|
||||
|
||||
impl Config {
|
||||
|
@ -63,15 +68,19 @@ impl Config {
|
|||
];
|
||||
let mut found_config = false;
|
||||
for (file, format) in &config_files {
|
||||
let source =
|
||||
config::File::from(config_dir.join(file)).format(*format).required(false);
|
||||
let source = config::File::from(config_dir.join(file))
|
||||
.format(*format)
|
||||
.required(false);
|
||||
builder = builder.add_source(source);
|
||||
if config_dir.join(file).exists() {
|
||||
found_config = true
|
||||
}
|
||||
}
|
||||
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()?;
|
||||
|
@ -126,14 +135,18 @@ impl<'de> Deserialize<'de> for KeyBindings {
|
|||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
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
|
||||
.into_iter()
|
||||
.map(|(mode, inner_map)| {
|
||||
let converted_inner_map = inner_map
|
||||
.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();
|
||||
(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> {
|
||||
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));
|
||||
}
|
||||
let raw = if !raw.contains("><") {
|
||||
|
@ -321,7 +336,9 @@ impl<'de> Deserialize<'de> for Styles {
|
|||
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
where
|
||||
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
|
||||
.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();
|
||||
Some(Color::Indexed(c))
|
||||
} 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))
|
||||
} else if s.contains("rgb") {
|
||||
let red = (s.as_bytes()[3] 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 red =
|
||||
(s.as_bytes()[3] 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;
|
||||
Some(Color::Indexed(c))
|
||||
} else if s == "bold black" {
|
||||
|
@ -466,7 +487,8 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
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!(modifiers.contains(Modifier::UNDERLINED));
|
||||
assert!(modifiers.contains(Modifier::BOLD));
|
||||
|
@ -540,12 +562,18 @@ mod tests {
|
|||
fn test_multiple_modifiers() {
|
||||
assert_eq!(
|
||||
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!(
|
||||
parse_key_event("ctrl-shift-enter").unwrap(),
|
||||
KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT)
|
||||
KeyEvent::new(
|
||||
KeyCode::Enter,
|
||||
KeyModifiers::CONTROL | KeyModifiers::SHIFT
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -21,8 +21,9 @@ pub fn init() -> Result<()> {
|
|||
let metadata = metadata!();
|
||||
let file_path = handle_dump(&metadata, panic_info);
|
||||
// prints human-panic message
|
||||
print_msg(file_path, &metadata)
|
||||
.expect("human-panic: printing error message to console failed");
|
||||
print_msg(file_path, &metadata).expect(
|
||||
"human-panic: printing error message to console failed",
|
||||
);
|
||||
eprintln!("{}", panic_hook.panic_report(panic_info)); // prints color-eyre stack trace to stderr
|
||||
}
|
||||
let msg = format!("{}", panic_hook.panic_report(panic_info));
|
||||
|
|
|
@ -99,9 +99,15 @@ impl KeyCodeExt for KeyCode {
|
|||
|
||||
fn into_key_event(self) -> KeyEvent {
|
||||
match self {
|
||||
Self::Char(CTRL_C) => KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL),
|
||||
Self::Char(CTRL_D) => KeyEvent::new(KeyCode::Char('d'), KeyModifiers::CONTROL),
|
||||
Self::Char(CTRL_Z) => KeyEvent::new(KeyCode::Char('z'), KeyModifiers::CONTROL),
|
||||
Self::Char(CTRL_C) => {
|
||||
KeyEvent::new(KeyCode::Char('c'), 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()),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,7 +10,11 @@ macro_rules! embedded_route {
|
|||
($path:expr) => {
|
||||
match WebLandingServer::get($path) {
|
||||
Some(content) => HttpResponse::Ok()
|
||||
.content_type(mime_guess::from_path($path).first_or_octet_stream().as_ref())
|
||||
.content_type(
|
||||
mime_guess::from_path($path)
|
||||
.first_or_octet_stream()
|
||||
.as_ref(),
|
||||
)
|
||||
.body(content.data.into_owned()),
|
||||
None => HttpResponse::NotFound().body("404 Not Found"),
|
||||
}
|
||||
|
@ -41,7 +45,11 @@ impl WebLandingServer {
|
|||
tracing::info!("Web server listening on {}", addr);
|
||||
HttpServer::new(|| {
|
||||
// TODO: register a default service for a nicer 404 page
|
||||
App::new().service(index).service(favicon).service(dist).wrap(Logger::default())
|
||||
App::new()
|
||||
.service(index)
|
||||
.service(favicon)
|
||||
.service(dist)
|
||||
.wrap(Logger::default())
|
||||
})
|
||||
.bind(addr)?
|
||||
.run()
|
||||
|
|
|
@ -30,16 +30,20 @@ pub fn init() -> Result<()> {
|
|||
//
|
||||
|
||||
// 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(
|
||||
if cfg!(debug_assertions) {
|
||||
tracing::Level::DEBUG.into()
|
||||
} else {
|
||||
tracing::Level::INFO.into()
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Stage 2: Attempt to read from {RUST|CRATE_NAME}_LOG env var or ignore
|
||||
let env_filter = env_filter
|
||||
.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("tui_markdown=info".parse().unwrap());
|
||||
|
||||
|
@ -82,7 +86,9 @@ pub fn init() -> Result<()> {
|
|||
let layer = layer
|
||||
.compact()
|
||||
.without_time()
|
||||
.with_span_events(tracing_subscriber::fmt::format::FmtSpan::NONE)
|
||||
.with_span_events(
|
||||
tracing_subscriber::fmt::format::FmtSpan::NONE,
|
||||
)
|
||||
.with_target(false)
|
||||
.with_thread_ids(false);
|
||||
layer
|
||||
|
|
12
src/main.rs
12
src/main.rs
|
@ -50,8 +50,10 @@ async fn main() -> Result<()> {
|
|||
crate::logging::init()?;
|
||||
let _ = *OPTIONS; // force clap to run by evaluating it
|
||||
|
||||
let ssh_socket_addr = SSH_SOCKET_ADDR.ok_or(eyre!("Invalid host IP provided"))?;
|
||||
let web_server_addr = WEB_SERVER_ADDR.ok_or(eyre!("Invalid host IP provided"))?;
|
||||
let ssh_socket_addr =
|
||||
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! {
|
||||
ssh_res = SshServer::start(ssh_socket_addr, ssh_config()) => ssh_res,
|
||||
|
@ -66,9 +68,9 @@ pub fn host_ip() -> Result<[u8; 4]> {
|
|||
.host
|
||||
.splitn(4, ".")
|
||||
.map(|octet_str| {
|
||||
octet_str
|
||||
.parse::<u8>()
|
||||
.map_err(|_| eyre!("Octet component out of range (expected u8)"))
|
||||
octet_str.parse::<u8>().map_err(|_| {
|
||||
eyre!("Octet component out of range (expected u8)")
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<u8>>>()?,
|
||||
)
|
||||
|
|
95
src/ssh.rs
95
src/ssh.rs
|
@ -13,7 +13,6 @@ 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;
|
||||
|
||||
|
@ -51,7 +50,10 @@ 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())
|
||||
}
|
||||
|
@ -65,7 +67,6 @@ 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)>,
|
||||
|
@ -80,18 +81,13 @@ 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,
|
||||
resize_rx,
|
||||
)
|
||||
.inspect_err(|err| tracing::error!("Failed to create app: {err}"))
|
||||
.ok()
|
||||
.map(|app| Arc::new(Mutex::new(app))),
|
||||
tui: Arc::new(RwLock::new(None)),
|
||||
|
@ -104,13 +100,16 @@ impl SshSession {
|
|||
|
||||
async fn run_app(
|
||||
app: Arc<Mutex<App>>,
|
||||
term: Arc<Mutex<Terminal>>,
|
||||
writer: Arc<Mutex<Terminal>>,
|
||||
tui: Arc<RwLock<Option<Tui>>>,
|
||||
session: &Handle,
|
||||
channel_id: ChannelId,
|
||||
) -> eyre::Result<()> {
|
||||
app.lock_owned().await.run(term, tui).await?;
|
||||
session.close(channel_id).await.map_err(|_| eyre!("failed to close session"))?;
|
||||
app.lock_owned().await.run(writer, tui).await?;
|
||||
session
|
||||
.close(channel_id)
|
||||
.await
|
||||
.map_err(|_| eyre!("failed to close session"))?;
|
||||
session
|
||||
.exit_status_request(channel_id, 0)
|
||||
.await
|
||||
|
@ -143,10 +142,8 @@ 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,
|
||||
|
@ -155,8 +152,7 @@ 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(())
|
||||
};
|
||||
|
||||
|
@ -164,7 +160,8 @@ 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -175,31 +172,6 @@ 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,
|
||||
|
@ -213,19 +185,10 @@ impl Handler for SshSession {
|
|||
session: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
tracing::info!("PTY requested by terminal: {term}");
|
||||
tracing::debug!("dims: {col_width} * {row_height}, pixel: {pix_width} * {pix_height}");
|
||||
|
||||
if pix_width != 0 && pix_height != 0 {
|
||||
self.terminal_info.write().await.set_font_size((
|
||||
(pix_width / col_width).try_into().or(Err(eyre!("Terminal too wide")))?,
|
||||
(pix_height / row_height).try_into().or(Err(eyre!("Terminal too tall")))?,
|
||||
));
|
||||
} else {
|
||||
self.terminal_info
|
||||
.write()
|
||||
.await
|
||||
.set_kind(TerminalKind::Unsupported(UnsupportedReason::Unsized));
|
||||
}
|
||||
tracing::debug!(
|
||||
"dims: {col_width} * {row_height}, pixel: {pix_width} * \
|
||||
{pix_height}"
|
||||
);
|
||||
|
||||
if !term.contains("xterm") {
|
||||
session.channel_failure(channel_id)?;
|
||||
|
@ -254,7 +217,10 @@ 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"))
|
||||
|
@ -265,15 +231,12 @@ impl Handler for SshSession {
|
|||
_: ChannelId,
|
||||
col_width: u32,
|
||||
row_height: u32,
|
||||
pix_width: u32,
|
||||
pix_height: u32,
|
||||
_: u32,
|
||||
_: u32,
|
||||
_: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
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")))?,
|
||||
));
|
||||
|
||||
// TODO: actually make it resize properly
|
||||
// That would involve first updating the Backend's size and then updating the rect via the event
|
||||
self.resize_tx
|
||||
.send((col_width as u16, row_height as u16))
|
||||
.map_err(|_| eyre!("Failed to send pty size specifications"))?;
|
||||
|
@ -290,7 +253,9 @@ 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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -49,7 +49,9 @@ impl Backend for SshBackend {
|
|||
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()
|
||||
}
|
||||
|
||||
|
|
|
@ -7,8 +7,8 @@ use backend::SshBackend;
|
|||
use color_eyre::Result;
|
||||
use crossterm::cursor;
|
||||
use crossterm::event::{
|
||||
DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
|
||||
KeyEvent, MouseEvent,
|
||||
DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste,
|
||||
EnableMouseCapture, KeyEvent, MouseEvent,
|
||||
};
|
||||
use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
@ -23,7 +23,6 @@ use tracing::error;
|
|||
|
||||
pub(crate) mod backend;
|
||||
pub(crate) mod status;
|
||||
pub(crate) mod terminal;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub enum Event {
|
||||
|
@ -118,8 +117,10 @@ impl Tui {
|
|||
tick_rate: f64,
|
||||
frame_rate: f64,
|
||||
) {
|
||||
let mut tick_interval = interval(Duration::from_secs_f64(1.0 / tick_rate));
|
||||
let mut render_interval = interval(Duration::from_secs_f64(1.0 / frame_rate));
|
||||
let mut tick_interval =
|
||||
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
|
||||
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() {
|
||||
timeout(attempt_timeout, abort_shutdown).await.inspect_err(|_| {
|
||||
error!("Failed to abort task in 100 milliseconds for unknown reason")
|
||||
})?;
|
||||
timeout(attempt_timeout, abort_shutdown).await.inspect_err(
|
||||
|_| {
|
||||
error!(
|
||||
"Failed to abort task in 100 milliseconds for unknown \
|
||||
reason"
|
||||
)
|
||||
},
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
@ -171,7 +177,11 @@ impl Tui {
|
|||
pub fn enter(&mut self) -> Result<()> {
|
||||
let mut term = self.terminal.try_lock()?;
|
||||
// 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 {
|
||||
crossterm::execute!(term.backend_mut(), EnableMouseCapture)?;
|
||||
|
@ -201,7 +211,11 @@ impl Tui {
|
|||
crossterm::execute!(term.backend_mut(), DisableMouseCapture)?;
|
||||
}
|
||||
|
||||
crossterm::execute!(term.backend_mut(), LeaveAlternateScreen, cursor::Show)?;
|
||||
crossterm::execute!(
|
||||
term.backend_mut(),
|
||||
LeaveAlternateScreen,
|
||||
cursor::Show
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
@ -216,7 +230,8 @@ impl Tui {
|
|||
// Update the status and initialize a cancellation token
|
||||
let token = Arc::new(CancellationToken::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
|
||||
let status = Arc::clone(&self.status);
|
||||
|
@ -247,7 +262,9 @@ impl Drop for Tui {
|
|||
block_in_place(|| {
|
||||
let handle = Handle::current();
|
||||
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}"))
|
||||
});
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue