feat(blog): make blog title list interactive

This commit is contained in:
Erica Marigold 2025-08-12 08:18:49 +01:00
parent 4d6d8974e4
commit b6bf444fcf
Signed by: DevComp
SSH key fingerprint: SHA256:jD3oMT4WL3WHPJQbrjC3l5feNCnkv7ndW8nYaHX5wFw
8 changed files with 134 additions and 39 deletions

View file

@ -8,6 +8,8 @@
"<Ctrl-z>": "Suspend", // Suspend the application "<Ctrl-z>": "Suspend", // Suspend the application
"<right>": "NextTab", // Go to the next tab "<right>": "NextTab", // Go to the next tab
"<left>": "PrevTab", // Go to the previous tab "<left>": "PrevTab", // Go to the previous tab
"<down>": "SelectNext", // Go to the next selection in options
"<up>": "SelectPrev", // Go to the previous selection in options
}, },
} }
} }

View file

@ -15,5 +15,9 @@ pub enum Action {
// Tab management // Tab management
NextTab, NextTab,
PrevTab PrevTab,
// Selection management
SelectNext,
SelectPrev,
} }

View file

@ -43,6 +43,8 @@ pub struct App {
tabs: Arc<Mutex<Tabs>>, tabs: Arc<Mutex<Tabs>>,
content: Arc<Mutex<Content>>, content: Arc<Mutex<Content>>,
cat: Arc<Mutex<Cat>>, cat: Arc<Mutex<Cat>>,
#[cfg(feature = "blog")]
selection_list: Arc<Mutex<SelectionList>>,
} }
#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
@ -70,6 +72,13 @@ impl App {
let cat = Arc::new(Mutex::new(Cat::new())); let cat = Arc::new(Mutex::new(Cat::new()));
#[cfg(feature = "blog")]
let rt = tokio::runtime::Handle::current();
#[cfg(feature = "blog")]
let selection_list = Arc::new(Mutex::new(SelectionList::new(
rt.block_on(content.blocking_lock().blog_content())?,
)));
Ok(Self { Ok(Self {
tick_rate, tick_rate,
frame_rate, frame_rate,
@ -87,6 +96,7 @@ impl App {
tabs, tabs,
content, content,
cat, cat,
selection_list,
}) })
} }
@ -248,6 +258,11 @@ impl App {
if let Some(action) = self.cat.try_lock()?.update(action.clone())? { if let Some(action) = self.cat.try_lock()?.update(action.clone())? {
self.action_tx.send(action)?; self.action_tx.send(action)?;
} }
#[cfg(feature = "blog")]
if let Some(action) = self.selection_list.try_lock()?.update(action.clone())? {
self.action_tx.send(action)?;
}
} }
Ok(()) Ok(())
} }
@ -287,33 +302,34 @@ impl App {
); );
// Render the tabs // Render the tabs
self.tabs let mut tabs = self
.tabs
.try_lock() .try_lock()
.map_err(|err| std::io::Error::other(err))?
.draw(
frame,
Rect {
x: chunks[0].x + 14,
y: chunks[0].y + 1,
width: chunks[0].width - 6,
height: chunks[0].height,
},
)
.map_err(|err| std::io::Error::other(err))?; .map_err(|err| std::io::Error::other(err))?;
tabs.draw(
frame,
Rect {
x: chunks[0].x + 14,
y: chunks[0].y + 1,
width: chunks[0].width - 6,
height: chunks[0].height,
},
)
.map_err(|err| std::io::Error::other(err))?;
// Render the content // Render the content
let mut content_rect = Rect {
x: chunks[1].x,
y: chunks[1].y,
width: chunks[0].width,
height: frame.area().height - chunks[0].height,
};
self.content self.content
.try_lock() .try_lock()
.map_err(|err| std::io::Error::other(err))? .map_err(|err| std::io::Error::other(err))?
.draw( .draw(frame, content_rect)
frame,
Rect {
x: chunks[1].x,
y: chunks[1].y,
width: chunks[0].width,
height: frame.area().height - chunks[0].height,
},
)
.map_err(|err| std::io::Error::other(err))?; .map_err(|err| std::io::Error::other(err))?;
// Render the eepy cat :3 // Render the eepy cat :3
@ -323,6 +339,24 @@ impl App {
.draw(frame, frame.area()) .draw(frame, frame.area())
.map_err(|err| std::io::Error::other(err))?; .map_err(|err| std::io::Error::other(err))?;
#[cfg(feature = "blog")]
if tabs.current_tab() == 2 {
// Render the post selection list if the blog tab is selected
content_rect.x += 1;
content_rect.y += 1;
content_rect.width -= 2;
content_rect.height -= 2;
self.selection_list
.try_lock()
.map_err(|err| std::io::Error::other(err))?
.draw(
frame,
content_rect,
)
.map_err(|err| std::io::Error::other(err))?;
}
Ok::<_, std::io::Error>(()) Ok::<_, std::io::Error>(())
})?; })?;
Ok(()) Ok(())

View file

@ -15,10 +15,12 @@ use crate::{action::Action, config::Config, tui::Event};
mod tabs; mod tabs;
mod content; mod content;
mod cat; mod cat;
mod selection_list;
pub use tabs::*; pub use tabs::*;
pub use content::*; pub use content::*;
pub use cat::*; pub use cat::*;
pub use selection_list::*;
/// `Component` is a trait that represents a visual and interactive element of the user interface. /// `Component` is a trait that represents a visual and interactive element of the user interface.
/// ///

View file

@ -177,12 +177,12 @@ impl Content {
/// Generate the content for the "Blog" tab /// Generate the content for the "Blog" tab
#[cfg(feature = "blog")] #[cfg(feature = "blog")]
async fn blog_content(&self) -> Result<Vec<Line<'static>>> { pub async fn blog_content(&self) -> Result<Vec<String>> {
Ok(crate::atproto::blog::get_all_posts() Ok(crate::atproto::blog::get_all_posts()
.await? .await?
.iter() .iter()
.map(|post| Line::from(post.title.clone().unwrap_or("<unknown>".to_string()))) .map(|post| post.title.clone().unwrap_or("<unknown>".to_string()))
.collect()) .collect::<Vec<String>>())
} }
} }
@ -207,22 +207,13 @@ impl Component for Content {
} }
fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> { fn draw(&mut self, frame: &mut Frame, area: Rect) -> Result<()> {
let content = match self.selected_tab.load(Ordering::Relaxed) { let selected_tab = self.selected_tab.load(Ordering::Relaxed);
let content = match selected_tab {
0 => self.about_content(area)?, 0 => self.about_content(area)?,
1 => self.projects_content(), 1 => self.projects_content(),
2 => { /* Blog tab handled in `App::render` */
#[cfg(feature = "blog")]
{ _ => vec![],
let rt = tokio::runtime::Handle::current();
rt.block_on(self.blog_content())?
}
#[cfg(not(feature = "blog"))]
vec![Line::from(
"Blog feature is disabled. Enable the `blog` feature to view this tab.",
)]
}
_ => unreachable!(),
}; };
// Create the border lines // Create the border lines

View file

@ -0,0 +1,58 @@
use color_eyre::eyre::Result;
use ratatui::{
style::{Color, Style},
widgets::{List, ListState},
};
use tokio::sync::mpsc::UnboundedSender;
use crate::{action::Action, components::Component, config::Config};
#[derive(Default)]
pub struct SelectionList {
command_tx: Option<UnboundedSender<Action>>,
config: Config,
options: List<'static>,
list_state: ListState,
}
impl SelectionList {
pub fn new(options: Vec<String>) -> Self {
let mut list_state = ListState::default();
list_state.select_first();
Self {
options: List::new(options).highlight_style(Style::default().fg(Color::Yellow)),
list_state,
..Default::default()
}
}
}
impl Component for SelectionList {
fn register_action_handler(&mut self, tx: UnboundedSender<Action>) -> Result<()> {
self.command_tx = Some(tx);
Ok(())
}
fn register_config_handler(&mut self, config: Config) -> Result<()> {
self.config = config;
Ok(())
}
fn update(&mut self, action: Action) -> Result<Option<Action>> {
match action {
Action::Tick => {}
Action::Render => {}
Action::SelectNext => self.list_state.select_next(),
Action::SelectPrev => self.list_state.select_previous(),
_ => {}
};
Ok(None)
}
fn draw(&mut self, frame: &mut ratatui::Frame, area: ratatui::prelude::Rect) -> Result<()> {
frame.render_stateful_widget(self.options.clone(), area, &mut self.list_state);
Ok(())
}
}

View file

@ -35,6 +35,10 @@ impl Tabs {
self.selected_tab.fetch_sub(1, Ordering::Relaxed); self.selected_tab.fetch_sub(1, Ordering::Relaxed);
} }
} }
pub fn current_tab(&self) -> usize {
self.selected_tab.load(Ordering::Relaxed)
}
} }
impl Component for Tabs { impl Component for Tabs {

View file

@ -258,7 +258,7 @@ impl Server for SshServer {
#[instrument(skip(self))] #[instrument(skip(self))]
fn new_client(&mut self, peer_addr: Option<SocketAddr>) -> Self::Handler { fn new_client(&mut self, peer_addr: Option<SocketAddr>) -> Self::Handler {
let session = SshSession::new(); let session = tokio::task::block_in_place(|| SshSession::new());
session session
} }
} }