Compare commits
1 commit
main
...
fix/ssh-lo
Author | SHA1 | Date | |
---|---|---|---|
0c127520f2 |
27 changed files with 584 additions and 2817 deletions
2283
Cargo.lock
generated
2283
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
21
Cargo.toml
21
Cargo.toml
|
@ -10,7 +10,6 @@ build = "build.rs"
|
|||
# TODO: CLI feature
|
||||
default = ["blog"]
|
||||
blog = [
|
||||
# Main deps
|
||||
"dep:atrium-api",
|
||||
"dep:atrium-xrpc",
|
||||
"dep:atrium-xrpc-client",
|
||||
|
@ -18,21 +17,9 @@ blog = [
|
|||
"dep:reqwest",
|
||||
"dep:ipld-core",
|
||||
"dep:tui-markdown",
|
||||
"dep:chrono",
|
||||
"dep:ratatui-image",
|
||||
"dep:image",
|
||||
|
||||
# Build deps
|
||||
"dep:atrium-codegen",
|
||||
"dep:patch-crate"
|
||||
"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"
|
||||
|
@ -55,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"
|
||||
|
@ -70,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"] }
|
||||
|
@ -88,7 +72,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", optional = true }
|
||||
patch-crate = { version = "0.1.13", optional = true }
|
||||
atrium-codegen = { git = "https://github.com/atrium-rs/atrium.git", rev = "ccc0213" }
|
||||
ssh-key = { version = "0.6.7", features = ["getrandom", "crypto"] }
|
||||
vergen-gix = { version = "1.0.2", features = ["build", "cargo"] }
|
||||
|
|
23
Dockerfile
23
Dockerfile
|
@ -1,23 +0,0 @@
|
|||
FROM rust:1.87-alpine3.21 AS base
|
||||
|
||||
ARG CARGO_FEATURES="blog"
|
||||
|
||||
FROM base AS install
|
||||
WORKDIR /temp/dev
|
||||
COPY patches/ rust-toolchain Cargo.toml Cargo.lock .
|
||||
RUN cargo install patch-crate --locked && cargo patch-crate
|
||||
RUN mkdir src && touch src/lib.rs
|
||||
RUN mkdir .cargo && cargo vendor --locked >> .cargo/config.toml
|
||||
|
||||
FROM base AS builder
|
||||
WORKDIR /usr/src/app
|
||||
COPY --from=install /temp/dev/vendor /temp/dev/.cargo .
|
||||
COPY . .
|
||||
RUN cargo build --release --no-default-features --features $CARGO_FEATURES
|
||||
|
||||
FROM scratch AS runner
|
||||
USER runner
|
||||
EXPOSE 80/tcp 22/tcp
|
||||
COPY --from=builder /usr/src/app/target/release/ssh-portfolio /usr/local/bin/ssh-portfolio
|
||||
|
||||
CMD ["/usr/local/bin/ssh-portfolio"]
|
9
build.rs
9
build.rs
|
@ -18,11 +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/");
|
||||
|
||||
#[cfg(feature = "blog")]
|
||||
patch_crate::run().expect("Failed while patching");
|
||||
|
||||
// Generate openSSH host keys
|
||||
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
|
||||
|
@ -37,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 {
|
||||
|
|
117
src/app.rs
117
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,36 +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())?;
|
||||
|
||||
for _ in 1..50 {
|
||||
if matches!(
|
||||
self.terminal_info.blocking_read().kind(),
|
||||
TerminalKind::Unsupported(UnsupportedReason::Unprobed)
|
||||
) {
|
||||
tracing::debug!("Waiting for 5s for terminal info 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>(())
|
||||
})?;
|
||||
|
@ -252,15 +258,17 @@ impl App {
|
|||
Action::Tick => {
|
||||
self.last_tick_key_events.drain(..);
|
||||
}
|
||||
#[cfg(feature = "blog")]
|
||||
Action::Quit => self.should_quit = !self.blog_posts.try_lock()?.is_in_post(),
|
||||
#[cfg(not(feature = "blog"))]
|
||||
Action::Quit => self.should_quit = true,
|
||||
Action::Quit => {
|
||||
if !self.blog_posts.try_lock()?.is_in_post() {
|
||||
self.should_quit = true;
|
||||
}
|
||||
}
|
||||
Action::Suspend => self.should_suspend = true,
|
||||
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)?,
|
||||
|
@ -268,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())? {
|
||||
|
@ -279,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)?;
|
||||
}
|
||||
}
|
||||
|
@ -331,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 }),
|
||||
|
@ -347,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
|
||||
|
@ -358,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,
|
||||
|
@ -418,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(
|
||||
ReqwestClient::new("https://bsky.social"),
|
||||
MemorySessionStore::default(),
|
||||
));
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -18,7 +18,7 @@ pub struct Cli {
|
|||
#[arg(short = 'H', long, value_name = "ADDRESS", default_value_t = String::from("127.0.0.1"))]
|
||||
pub host: String,
|
||||
/// The port to start the SSH server on
|
||||
#[arg(short = 'P', long, value_name = "PORT", default_value_t = 22)]
|
||||
#[arg(short = 'P', long, value_name = "PORT", default_value_t = 2222)]
|
||||
pub ssh_port: u16,
|
||||
/// The port to start the web server on
|
||||
#[arg(short = 'p', long, value_name = "PORT", default_value_t = 80)]
|
||||
|
|
|
@ -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,75 +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]),
|
||||
// NOTE: Multiplexers such as tmux are currently unsupported, we ensure that we have an
|
||||
// xterm based terminal emulator in ssh.rs, if not, we reject the conection to begin with
|
||||
is_tmux: false,
|
||||
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)
|
||||
}
|
||||
|
||||
|
@ -109,11 +51,11 @@ impl Component for BlogPosts {
|
|||
// safe to unwrap, guaranteed to not be `None`
|
||||
Action::Tick => {}
|
||||
Action::Render => {}
|
||||
Action::Quit | Action::PrevTab | Action::NextTab => self.in_post = (None, None),
|
||||
|
||||
// FIXME: This makes it possible to scroll through the list with arrow keys even
|
||||
// when it is not rendered, which is not ideal; should probably fix later, minor bug
|
||||
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,
|
||||
_ => {}
|
||||
};
|
||||
|
||||
|
@ -125,69 +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"))?;
|
||||
.map_or(String::from("404 - Blog not found!"), |post| {
|
||||
post.content.clone()
|
||||
});
|
||||
|
||||
let post_body = post.title.clone().map_or(post.content.clone(), |title| {
|
||||
format!("# {}\n\n{}", title, post.content)
|
||||
});
|
||||
|
||||
let post_body_widget =
|
||||
Paragraph::new(tui_markdown::from_str(&post_body)).wrap(Wrap { trim: true });
|
||||
|
||||
// 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(post_body_widget, 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_body_widget,
|
||||
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_body_widget,
|
||||
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,13 +14,6 @@ use crate::action::Action;
|
|||
use crate::components::Post;
|
||||
use crate::config::Config;
|
||||
|
||||
#[allow(dead_code)]
|
||||
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>>,
|
||||
|
@ -42,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()
|
||||
|
@ -58,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))
|
||||
|
@ -70,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()
|
||||
|
@ -88,79 +89,98 @@ 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 split_point = project
|
||||
.char_indices()
|
||||
.take_while(|(i, _)| *i < area.width as usize - bullet.len())
|
||||
.last()
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(project.len());
|
||||
let (first, rest) = project.split_at(split_point);
|
||||
content.push(Line::from(vec![
|
||||
Span::from(bullet),
|
||||
formatted_left,
|
||||
Span::from(":"),
|
||||
Span::styled(
|
||||
first.trim_start_matches(format!("{left}:").as_str()).to_string(),
|
||||
Style::default().fg(Color::White),
|
||||
),
|
||||
]));
|
||||
rest.to_string()
|
||||
} else {
|
||||
content.push(Line::from(vec![
|
||||
Span::from(bullet),
|
||||
formatted_left,
|
||||
Span::from(":"),
|
||||
Span::styled(right.to_string(), Style::default().fg(Color::White)),
|
||||
]));
|
||||
String::new()
|
||||
};
|
||||
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()
|
||||
})
|
||||
.last()
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(project.len());
|
||||
let (first, rest) = project.split_at(split_point);
|
||||
content.push(Line::from(vec![
|
||||
Span::from(bullet),
|
||||
formatted_left,
|
||||
Span::from(":"),
|
||||
Span::styled(
|
||||
first
|
||||
.trim_start_matches(format!("{left}:").as_str())
|
||||
.to_string(),
|
||||
Style::default().fg(Color::White),
|
||||
),
|
||||
]));
|
||||
rest.to_string()
|
||||
} else {
|
||||
content.push(Line::from(vec![
|
||||
Span::from(bullet),
|
||||
formatted_left,
|
||||
Span::from(":"),
|
||||
Span::styled(
|
||||
right.to_string(),
|
||||
Style::default().fg(Color::White),
|
||||
),
|
||||
]));
|
||||
String::new()
|
||||
};
|
||||
|
||||
let mut remaining_text = first_line;
|
||||
while !remaining_text.is_empty() {
|
||||
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 {
|
||||
|
@ -179,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",
|
||||
),
|
||||
]);
|
||||
|
||||
|
@ -204,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(())
|
||||
}
|
||||
|
@ -234,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(
|
||||
|
@ -246,11 +271,17 @@ 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))
|
||||
} else {
|
||||
("─", Style::default().fg(Color::DarkGray))
|
||||
};
|
||||
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))
|
||||
};
|
||||
|
||||
let default_style = Style::default().fg(Color::DarkGray);
|
||||
|
||||
|
@ -268,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)
|
||||
|
@ -301,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 {
|
||||
|
@ -312,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,13 +106,8 @@ impl Component for SelectionList<Post> {
|
|||
];
|
||||
|
||||
let subtitle_span = Span::raw(
|
||||
[
|
||||
" ",
|
||||
post.subtitle
|
||||
.as_ref()
|
||||
.unwrap_or(&super::truncate(post.content.as_ref(), 40)),
|
||||
]
|
||||
.concat(),
|
||||
[" ", post.subtitle.as_ref().unwrap_or(&truncate(post.content.as_ref(), 40))]
|
||||
.concat(),
|
||||
);
|
||||
|
||||
list_content.push(Line::from([line_format.as_slice(), &[subtitle_span]].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) {
|
||||
tracing::Level::DEBUG.into()
|
||||
} else {
|
||||
tracing::Level::INFO.into()
|
||||
});
|
||||
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>>>()?,
|
||||
)
|
||||
|
|
120
src/ssh.rs
120
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};
|
||||
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,12 +81,8 @@ 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,
|
||||
|
@ -104,13 +101,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,28 +143,26 @@ impl Handler for SshSession {
|
|||
|
||||
tracing::info!("Serving app to open session");
|
||||
tokio::task::spawn(async move {
|
||||
let result =
|
||||
async || -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let ((term_width, term_height), (pixel_width, pixel_height)) =
|
||||
rx.await?;
|
||||
let writer = Arc::new(Mutex::new(Terminal::new(SshBackend::new(
|
||||
TermWriter::new(session_handle.clone(), channel),
|
||||
term_width,
|
||||
term_height,
|
||||
pixel_width,
|
||||
pixel_height,
|
||||
))?));
|
||||
let result = async || -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let ((term_width, term_height), (pixel_width, pixel_height)) = rx.await?;
|
||||
let writer = Arc::new(Mutex::new(Terminal::new(SshBackend::new(
|
||||
TermWriter::new(session_handle.clone(), channel),
|
||||
term_width,
|
||||
term_height,
|
||||
pixel_width,
|
||||
pixel_height,
|
||||
))?));
|
||||
|
||||
Self::run_app(inner_app, writer, tui, &session_handle, channel_id)
|
||||
.await?;
|
||||
Ok(())
|
||||
};
|
||||
Self::run_app(inner_app, writer, tui, &session_handle, channel_id).await?;
|
||||
Ok(())
|
||||
};
|
||||
|
||||
match result().await {
|
||||
Ok(()) => tracing::info!("Session exited successfully"),
|
||||
Err(err) => {
|
||||
tracing::error!("Session errored: {err}");
|
||||
let _ = session_handle.channel_failure(channel_id).await;
|
||||
let _ =
|
||||
session_handle.channel_failure(channel_id).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@ -175,31 +173,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,20 +186,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}");
|
||||
|
||||
#[cfg(feature = "blog")]
|
||||
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(crate::tui::terminal::UnsupportedReason::Unsized));
|
||||
}
|
||||
tracing::debug!(
|
||||
"dims: {col_width} * {row_height}, pixel: {pix_width} * \
|
||||
{pix_height}"
|
||||
);
|
||||
|
||||
if !term.contains("xterm") {
|
||||
session.channel_failure(channel_id)?;
|
||||
|
@ -255,31 +218,26 @@ 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"))
|
||||
}
|
||||
|
||||
#[instrument(skip_all, fields(channel_id = %_channel_id))]
|
||||
async fn window_change_request(
|
||||
&mut self,
|
||||
_channel_id: ChannelId,
|
||||
_: ChannelId,
|
||||
col_width: u32,
|
||||
row_height: u32,
|
||||
pix_width: u32,
|
||||
pix_height: u32,
|
||||
_: u32,
|
||||
_: u32,
|
||||
_: &mut Session,
|
||||
) -> Result<(), Self::Error> {
|
||||
tracing::info!("Terminal window resized by client, notifying components");
|
||||
tracing::debug!("dims: {col_width} * {row_height}, pixel: {pix_width} * {pix_height}");
|
||||
|
||||
#[cfg(feature = "blog")]
|
||||
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"))?;
|
||||
|
@ -296,7 +254,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,163 +0,0 @@
|
|||
use std::default::Default;
|
||||
|
||||
use default_variant::default;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use strum::Display;
|
||||
|
||||
#[cfg(feature = "blog")]
|
||||
use ratatui_image::{
|
||||
picker::{Capability, ProtocolType},
|
||||
FontSize,
|
||||
};
|
||||
|
||||
#[cfg(feature = "blog")]
|
||||
pub const DEFAULT_FONT_SIZE: FontSize = (12, 12);
|
||||
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct TerminalInfo {
|
||||
kind: TerminalKind,
|
||||
#[cfg(feature = "blog")]
|
||||
font_size: Option<FontSize>,
|
||||
}
|
||||
|
||||
impl TerminalInfo {
|
||||
/// Get the terminal kind.
|
||||
pub fn kind(&self) -> &TerminalKind {
|
||||
&self.kind
|
||||
}
|
||||
|
||||
/// Get the font size.
|
||||
#[cfg(feature = "blog")]
|
||||
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.
|
||||
#[cfg(feature = "blog")]
|
||||
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(", ")
|
||||
}
|
||||
|
||||
#[cfg(feature = "blog")]
|
||||
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![],
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "blog")]
|
||||
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