cleaned up help/menu to be less clutered when new features are added
This commit is contained in:
parent
f92e4f3130
commit
d3b180e1ea
|
@ -149,6 +149,23 @@ impl MastodonClient {
|
|||
Ok(account)
|
||||
}
|
||||
|
||||
pub async fn get_account(&self, id: &str) -> Result<Account> {
|
||||
let endpoint = format!("/api/v1/accounts/{}", id);
|
||||
let response = self.get(&endpoint).await?;
|
||||
let account: Account = response.json().await?;
|
||||
Ok(account)
|
||||
}
|
||||
|
||||
pub async fn get_account_statuses(&self, id: &str, limit: Option<u32>) -> Result<Vec<Status>> {
|
||||
let mut endpoint = format!("/api/v1/accounts/{}/statuses", id);
|
||||
if let Some(limit) = limit {
|
||||
endpoint.push_str(&format!("?limit={}", limit));
|
||||
}
|
||||
let response = self.get(&endpoint).await?;
|
||||
let statuses: Vec<Status> = response.json().await?;
|
||||
Ok(statuses)
|
||||
}
|
||||
|
||||
pub async fn get_home_timeline(&self, limit: Option<u32>) -> Result<Vec<Status>> {
|
||||
let mut endpoint = "/api/v1/timelines/home".to_string();
|
||||
if let Some(limit) = limit {
|
||||
|
|
242
src/ui/app.rs
242
src/ui/app.rs
|
@ -1,4 +1,5 @@
|
|||
use crate::api::{MastodonClient, Status, Notification};
|
||||
use crate::api::{MastodonClient, Status, Notification, Context, Account};
|
||||
use crate::api::client::SearchResults;
|
||||
use crate::ui::content::{ContentParser, ExtractedLink};
|
||||
use crate::ui::image::{MediaViewer, ImageViewer};
|
||||
use anyhow::Result;
|
||||
|
@ -14,6 +15,8 @@ pub enum AppMode {
|
|||
MediaView,
|
||||
LinksView,
|
||||
Search,
|
||||
ThreadView,
|
||||
ProfileView,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
|
@ -23,6 +26,13 @@ pub enum TimelineType {
|
|||
Federated,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum SearchCategory {
|
||||
Posts,
|
||||
Accounts,
|
||||
Hashtags,
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
pub client: Arc<MastodonClient>,
|
||||
pub mode: AppMode,
|
||||
|
@ -34,12 +44,20 @@ pub struct App {
|
|||
pub compose_text: String,
|
||||
pub reply_to_id: Option<String>,
|
||||
pub search_query: String,
|
||||
pub search_results: Option<SearchResults>,
|
||||
pub search_category: SearchCategory,
|
||||
pub selected_search_index: usize,
|
||||
pub thread_context: Option<Context>,
|
||||
pub thread_statuses: Vec<Status>,
|
||||
pub selected_thread_index: usize,
|
||||
pub current_status_id: Option<String>,
|
||||
pub running: bool,
|
||||
pub loading: bool,
|
||||
pub error_message: Option<String>,
|
||||
pub media_viewer: MediaViewer,
|
||||
pub current_links: Vec<ExtractedLink>,
|
||||
pub selected_link_index: usize,
|
||||
pub show_detailed_help: bool,
|
||||
}
|
||||
|
||||
impl App {
|
||||
|
@ -55,12 +73,20 @@ impl App {
|
|||
compose_text: String::new(),
|
||||
reply_to_id: None,
|
||||
search_query: String::new(),
|
||||
search_results: None,
|
||||
search_category: SearchCategory::Posts,
|
||||
selected_search_index: 0,
|
||||
thread_context: None,
|
||||
thread_statuses: Vec::new(),
|
||||
selected_thread_index: 0,
|
||||
current_status_id: None,
|
||||
running: true,
|
||||
loading: false,
|
||||
error_message: None,
|
||||
media_viewer: MediaViewer::new(),
|
||||
current_links: Vec::new(),
|
||||
selected_link_index: 0,
|
||||
show_detailed_help: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -325,6 +351,146 @@ impl App {
|
|||
}
|
||||
}
|
||||
|
||||
pub async fn perform_search(&mut self) -> Result<()> {
|
||||
if self.search_query.trim().is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.loading = true;
|
||||
self.error_message = None;
|
||||
|
||||
match self.client.search(&self.search_query, true).await {
|
||||
Ok(results) => {
|
||||
self.search_results = Some(results);
|
||||
self.selected_search_index = 0;
|
||||
}
|
||||
Err(e) => {
|
||||
self.error_message = Some(format!("Search failed: {}", e));
|
||||
}
|
||||
}
|
||||
|
||||
self.loading = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn next_search_category(&mut self) {
|
||||
self.search_category = match self.search_category {
|
||||
SearchCategory::Posts => SearchCategory::Accounts,
|
||||
SearchCategory::Accounts => SearchCategory::Hashtags,
|
||||
SearchCategory::Hashtags => SearchCategory::Posts,
|
||||
};
|
||||
self.selected_search_index = 0;
|
||||
}
|
||||
|
||||
pub fn next_search_result(&mut self) {
|
||||
if let Some(results) = &self.search_results {
|
||||
let max_index = match self.search_category {
|
||||
SearchCategory::Posts => results.statuses.len(),
|
||||
SearchCategory::Accounts => results.accounts.len(),
|
||||
SearchCategory::Hashtags => results.hashtags.len(),
|
||||
};
|
||||
|
||||
if self.selected_search_index < max_index.saturating_sub(1) {
|
||||
self.selected_search_index += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn previous_search_result(&mut self) {
|
||||
if self.selected_search_index > 0 {
|
||||
self.selected_search_index -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_search_result(&mut self) {
|
||||
if let Some(results) = &self.search_results {
|
||||
match self.search_category {
|
||||
SearchCategory::Posts => {
|
||||
if let Some(status) = results.statuses.get(self.selected_search_index) {
|
||||
// Add the status to timeline and view it
|
||||
self.statuses.insert(0, status.clone());
|
||||
self.selected_status_index = 0;
|
||||
self.mode = AppMode::StatusDetail;
|
||||
}
|
||||
}
|
||||
SearchCategory::Accounts => {
|
||||
// TODO: Implement user profile view
|
||||
self.error_message = Some("User profile view not implemented yet".to_string());
|
||||
}
|
||||
SearchCategory::Hashtags => {
|
||||
if let Some(tag) = results.hashtags.get(self.selected_search_index) {
|
||||
// Search for posts with this hashtag
|
||||
self.search_query = format!("#{}", tag.name);
|
||||
self.search_category = SearchCategory::Posts;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn view_thread(&mut self) -> Result<()> {
|
||||
if let Some(status) = self.statuses.get(self.selected_status_index) {
|
||||
let status_id = status.id.clone();
|
||||
self.current_status_id = Some(status_id.clone());
|
||||
|
||||
self.loading = true;
|
||||
self.error_message = None;
|
||||
|
||||
match self.client.get_status_context(&status_id).await {
|
||||
Ok(context) => {
|
||||
// Build the full thread: ancestors + current status + descendants
|
||||
let mut thread_statuses = context.ancestors.clone();
|
||||
thread_statuses.push(status.clone());
|
||||
thread_statuses.extend(context.descendants.clone());
|
||||
|
||||
self.thread_context = Some(context);
|
||||
self.thread_statuses = thread_statuses;
|
||||
|
||||
// Find the index of the current status in the thread
|
||||
self.selected_thread_index = self.thread_statuses
|
||||
.iter()
|
||||
.position(|s| s.id == status_id)
|
||||
.unwrap_or(0);
|
||||
|
||||
self.mode = AppMode::ThreadView;
|
||||
}
|
||||
Err(e) => {
|
||||
self.error_message = Some(format!("Failed to load thread: {}", e));
|
||||
}
|
||||
}
|
||||
|
||||
self.loading = false;
|
||||
} else {
|
||||
self.error_message = Some("No post selected".to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn next_thread_post(&mut self) {
|
||||
if self.selected_thread_index < self.thread_statuses.len().saturating_sub(1) {
|
||||
self.selected_thread_index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn previous_thread_post(&mut self) {
|
||||
if self.selected_thread_index > 0 {
|
||||
self.selected_thread_index -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reply_to_thread_post(&mut self) {
|
||||
if let Some(status) = self.thread_statuses.get(self.selected_thread_index) {
|
||||
self.reply_to_id = Some(status.id.clone());
|
||||
self.compose_text = format!("@{} ", status.account.acct);
|
||||
self.mode = AppMode::Compose;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn toggle_help(&mut self) {
|
||||
self.show_detailed_help = !self.show_detailed_help;
|
||||
}
|
||||
|
||||
pub fn quit(&mut self) {
|
||||
self.running = false;
|
||||
}
|
||||
|
@ -338,6 +504,7 @@ impl App {
|
|||
AppMode::MediaView => self.handle_media_key(key),
|
||||
AppMode::LinksView => self.handle_links_key(key),
|
||||
AppMode::Search => self.handle_search_key(key),
|
||||
AppMode::ThreadView => self.handle_thread_key(key),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -361,6 +528,16 @@ impl App {
|
|||
(KeyCode::Char('l'), _) => self.view_links(),
|
||||
(KeyCode::Char('d'), _) => self.debug_post(),
|
||||
(KeyCode::Char('o'), _) => self.open_current_media(),
|
||||
(KeyCode::Char('s'), _) => {
|
||||
self.search_query.clear();
|
||||
self.search_results = None;
|
||||
self.mode = AppMode::Search;
|
||||
},
|
||||
(KeyCode::Char('h'), _) => {
|
||||
// View thread (h for "hierarchy" or "history")
|
||||
// Will be handled by main event loop
|
||||
},
|
||||
(KeyCode::F(1), _) => self.toggle_help(),
|
||||
(KeyCode::Enter, _) => self.mode = AppMode::StatusDetail,
|
||||
_ => {}
|
||||
}
|
||||
|
@ -474,14 +651,69 @@ impl App {
|
|||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
|
||||
match (key.code, key.modifiers) {
|
||||
(KeyCode::Esc, _) => self.mode = AppMode::Timeline,
|
||||
(KeyCode::Char('c'), KeyModifiers::CONTROL) => self.mode = AppMode::Timeline,
|
||||
(KeyCode::Char(c), _) => {
|
||||
(KeyCode::Esc, _) | (KeyCode::Char('q'), _) => {
|
||||
self.mode = AppMode::Timeline;
|
||||
self.search_results = None;
|
||||
}
|
||||
(KeyCode::Char('c'), KeyModifiers::CONTROL) => {
|
||||
self.mode = AppMode::Timeline;
|
||||
self.search_results = None;
|
||||
}
|
||||
(KeyCode::Char(c), _) if self.search_results.is_none() => {
|
||||
// Still typing the search query
|
||||
self.search_query.push(c);
|
||||
}
|
||||
(KeyCode::Backspace, _) => {
|
||||
(KeyCode::Backspace, _) if self.search_results.is_none() => {
|
||||
self.search_query.pop();
|
||||
}
|
||||
(KeyCode::Enter, _) if self.search_results.is_none() => {
|
||||
// Trigger search - will be handled by the main loop
|
||||
}
|
||||
// Navigation in search results
|
||||
(KeyCode::Char('j'), _) | (KeyCode::Down, _) => self.next_search_result(),
|
||||
(KeyCode::Char('k'), _) | (KeyCode::Up, _) => self.previous_search_result(),
|
||||
(KeyCode::Char('t'), _) => self.next_search_category(),
|
||||
(KeyCode::Enter, _) => self.open_search_result(),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_thread_key(&mut self, key: KeyEvent) {
|
||||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
|
||||
match (key.code, key.modifiers) {
|
||||
(KeyCode::Esc, _) | (KeyCode::Char('q'), _) => {
|
||||
self.thread_context = None;
|
||||
self.thread_statuses.clear();
|
||||
self.selected_thread_index = 0;
|
||||
self.current_status_id = None;
|
||||
self.mode = AppMode::Timeline;
|
||||
}
|
||||
(KeyCode::Char('c'), KeyModifiers::CONTROL) => {
|
||||
self.thread_context = None;
|
||||
self.thread_statuses.clear();
|
||||
self.selected_thread_index = 0;
|
||||
self.current_status_id = None;
|
||||
self.mode = AppMode::Timeline;
|
||||
}
|
||||
(KeyCode::Char('j'), _) | (KeyCode::Down, _) => {
|
||||
self.next_thread_post();
|
||||
}
|
||||
(KeyCode::Char('k'), _) | (KeyCode::Up, _) => {
|
||||
self.previous_thread_post();
|
||||
}
|
||||
(KeyCode::Char('r'), _) => {
|
||||
self.reply_to_thread_post();
|
||||
}
|
||||
(KeyCode::Enter, _) => {
|
||||
// View selected post in detail
|
||||
if let Some(status) = self.thread_statuses.get(self.selected_thread_index) {
|
||||
// Add the status to the main timeline for detail view
|
||||
self.statuses.insert(0, status.clone());
|
||||
self.selected_status_index = 0;
|
||||
self.mode = AppMode::StatusDetail;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
|
423
src/ui/mod.rs
423
src/ui/mod.rs
|
@ -69,6 +69,12 @@ async fn run_app<B: Backend>(terminal: &mut Terminal<B>, app: &mut App) -> Resul
|
|||
(KeyCode::Char('b'), KeyModifiers::CONTROL) if app.mode == AppMode::Timeline => {
|
||||
app.toggle_reblog().await?;
|
||||
}
|
||||
(KeyCode::Enter, _) if app.mode == AppMode::Search && app.search_results.is_none() => {
|
||||
app.perform_search().await?;
|
||||
}
|
||||
(KeyCode::Char('h'), _) if app.mode == AppMode::Timeline => {
|
||||
app.view_thread().await?;
|
||||
}
|
||||
(KeyCode::F(5), _) => {
|
||||
if app.mode == AppMode::Timeline {
|
||||
app.load_timeline().await?;
|
||||
|
@ -94,13 +100,14 @@ fn ui(f: &mut Frame, app: &App) {
|
|||
|
||||
match app.mode {
|
||||
AppMode::Timeline => {
|
||||
let help_height = if app.show_detailed_help { 5 } else { 3 };
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([Constraint::Min(0), Constraint::Length(3)].as_ref())
|
||||
.constraints([Constraint::Min(0), Constraint::Length(help_height)].as_ref())
|
||||
.split(size);
|
||||
|
||||
timeline::render_timeline(f, app, chunks[0]);
|
||||
render_help(f, chunks[1]);
|
||||
render_help(f, chunks[1], app);
|
||||
}
|
||||
AppMode::Compose => {
|
||||
compose::render_compose(f, app, size);
|
||||
|
@ -120,43 +127,91 @@ fn ui(f: &mut Frame, app: &App) {
|
|||
AppMode::Search => {
|
||||
render_search(f, app, size);
|
||||
}
|
||||
AppMode::ThreadView => {
|
||||
render_thread_view(f, app, size);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_help(f: &mut Frame, area: Rect) {
|
||||
let help_text = vec![
|
||||
Line::from(vec![
|
||||
Span::styled("j/k", Style::default().fg(Color::Cyan)),
|
||||
Span::raw("/"),
|
||||
Span::styled("↑↓", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" navigate • "),
|
||||
Span::styled("r", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" reply • "),
|
||||
Span::styled("n", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" new post • "),
|
||||
Span::styled("t", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" timeline • "),
|
||||
Span::styled("Enter", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" detail • "),
|
||||
Span::styled("v", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" media • "),
|
||||
Span::styled("l", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" links • "),
|
||||
Span::styled("o", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" open • "),
|
||||
Span::styled("Ctrl+f", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" fav • "),
|
||||
Span::styled("Ctrl+b", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" boost • "),
|
||||
Span::styled("F5", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" refresh • "),
|
||||
Span::styled("q", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" quit"),
|
||||
]),
|
||||
];
|
||||
fn render_help(f: &mut Frame, area: Rect, app: &App) {
|
||||
let help_lines = if app.show_detailed_help {
|
||||
// Detailed help - organized by category
|
||||
vec![
|
||||
Line::from(vec![
|
||||
Span::styled("Navigation: ", Style::default().fg(Color::Yellow)),
|
||||
Span::styled("j/k", Style::default().fg(Color::Cyan)),
|
||||
Span::raw("/"),
|
||||
Span::styled("↑↓", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" move • "),
|
||||
Span::styled("Enter", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" detail • "),
|
||||
Span::styled("t", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" timeline"),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled("Content: ", Style::default().fg(Color::Yellow)),
|
||||
Span::styled("r", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" reply • "),
|
||||
Span::styled("n", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" new post • "),
|
||||
Span::styled("Ctrl+f", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" fav • "),
|
||||
Span::styled("Ctrl+b", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" boost"),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled("View: ", Style::default().fg(Color::Yellow)),
|
||||
Span::styled("v", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" media • "),
|
||||
Span::styled("l", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" links • "),
|
||||
Span::styled("h", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" thread • "),
|
||||
Span::styled("s", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" search • "),
|
||||
Span::styled("o", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" open"),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled("System: ", Style::default().fg(Color::Yellow)),
|
||||
Span::styled("F5", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" refresh • "),
|
||||
Span::styled("F1", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" toggle help • "),
|
||||
Span::styled("q", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" quit"),
|
||||
]),
|
||||
]
|
||||
} else {
|
||||
// Compact help - just essentials
|
||||
vec![
|
||||
Line::from(vec![
|
||||
Span::styled("j/k", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" nav • "),
|
||||
Span::styled("r", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" reply • "),
|
||||
Span::styled("n", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" post • "),
|
||||
Span::styled("s", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" search • "),
|
||||
Span::styled("h", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" thread • "),
|
||||
Span::styled("F1", Style::default().fg(Color::Gray)),
|
||||
Span::raw(" more • "),
|
||||
Span::styled("q", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" quit"),
|
||||
]),
|
||||
]
|
||||
};
|
||||
|
||||
let paragraph = Paragraph::new(help_text)
|
||||
.block(Block::default().borders(Borders::ALL).title("Help"))
|
||||
let title = if app.show_detailed_help {
|
||||
"Help (F1 to hide)"
|
||||
} else {
|
||||
"Help (F1 for more)"
|
||||
};
|
||||
|
||||
let paragraph = Paragraph::new(help_lines)
|
||||
.block(Block::default().borders(Borders::ALL).title(title))
|
||||
.style(Style::default().fg(Color::Gray));
|
||||
|
||||
f.render_widget(paragraph, area);
|
||||
|
@ -339,12 +394,300 @@ fn render_links_view(f: &mut Frame, app: &App, area: Rect) {
|
|||
f.render_widget(help, chunks[2]);
|
||||
}
|
||||
|
||||
fn render_search(f: &mut Frame, _app: &App, _area: Rect) {
|
||||
let paragraph = Paragraph::new("Search functionality coming soon!")
|
||||
.block(Block::default().borders(Borders::ALL).title("Search"))
|
||||
.style(Style::default().fg(Color::Yellow));
|
||||
fn render_search(f: &mut Frame, app: &App, area: Rect) {
|
||||
use crate::ui::app::SearchCategory;
|
||||
use crate::ui::content::ContentParser;
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3), // Header
|
||||
Constraint::Length(3), // Search input
|
||||
Constraint::Length(3), // Category tabs
|
||||
Constraint::Min(0), // Results
|
||||
Constraint::Length(3), // Instructions
|
||||
].as_ref())
|
||||
.split(area);
|
||||
|
||||
f.render_widget(paragraph, _area);
|
||||
// Header
|
||||
let header = Paragraph::new("Search")
|
||||
.block(Block::default().borders(Borders::ALL).title("Mastui"))
|
||||
.style(Style::default().fg(Color::Yellow));
|
||||
f.render_widget(header, chunks[0]);
|
||||
|
||||
// Search input
|
||||
let search_text = if app.loading {
|
||||
format!("Searching: {}...", app.search_query)
|
||||
} else {
|
||||
format!("Query: {}", app.search_query)
|
||||
};
|
||||
|
||||
let input = Paragraph::new(search_text)
|
||||
.block(Block::default().borders(Borders::ALL).title("Search Query"))
|
||||
.style(Style::default().fg(Color::White));
|
||||
f.render_widget(input, chunks[1]);
|
||||
|
||||
// Category tabs (only show if we have results)
|
||||
if app.search_results.is_some() {
|
||||
let categories = vec!["Posts", "Accounts", "Hashtags"];
|
||||
let selected_idx = match app.search_category {
|
||||
SearchCategory::Posts => 0,
|
||||
SearchCategory::Accounts => 1,
|
||||
SearchCategory::Hashtags => 2,
|
||||
};
|
||||
|
||||
let tabs_text = categories
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, &cat)| {
|
||||
if i == selected_idx {
|
||||
format!("[{}]", cat)
|
||||
} else {
|
||||
cat.to_string()
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" | ");
|
||||
|
||||
let tabs = Paragraph::new(tabs_text)
|
||||
.block(Block::default().borders(Borders::ALL).title("Categories"))
|
||||
.style(Style::default().fg(Color::Cyan));
|
||||
f.render_widget(tabs, chunks[2]);
|
||||
}
|
||||
|
||||
// Results
|
||||
if let Some(results) = &app.search_results {
|
||||
let content = match app.search_category {
|
||||
SearchCategory::Posts => {
|
||||
if results.statuses.is_empty() {
|
||||
"No posts found".to_string()
|
||||
} else {
|
||||
let parser = ContentParser::new();
|
||||
results.statuses
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, status)| {
|
||||
let prefix = if i == app.selected_search_index { "▶ " } else { " " };
|
||||
let content = parser.parse_content_simple(&status.content);
|
||||
let first_line = content.first()
|
||||
.map(|line| line.spans.iter().map(|span| span.content.as_ref()).collect::<String>())
|
||||
.unwrap_or_else(|| "No content".to_string());
|
||||
format!("{}@{}: {}", prefix, status.account.acct,
|
||||
if first_line.len() > 80 { format!("{}...", &first_line[..80]) } else { first_line })
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
}
|
||||
SearchCategory::Accounts => {
|
||||
if results.accounts.is_empty() {
|
||||
"No accounts found".to_string()
|
||||
} else {
|
||||
results.accounts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, account)| {
|
||||
let prefix = if i == app.selected_search_index { "▶ " } else { " " };
|
||||
format!("{}@{}: {}", prefix, account.acct, account.display_name)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
}
|
||||
SearchCategory::Hashtags => {
|
||||
if results.hashtags.is_empty() {
|
||||
"No hashtags found".to_string()
|
||||
} else {
|
||||
results.hashtags
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, tag)| {
|
||||
let prefix = if i == app.selected_search_index { "▶ " } else { " " };
|
||||
format!("{}#{}", prefix, tag.name)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let results_widget = Paragraph::new(content)
|
||||
.block(Block::default().borders(Borders::ALL).title("Results"))
|
||||
.wrap(Wrap { trim: true })
|
||||
.style(Style::default().fg(Color::White));
|
||||
f.render_widget(results_widget, chunks[3]);
|
||||
} else if !app.search_query.is_empty() && !app.loading {
|
||||
let msg = Paragraph::new("Press Enter to search")
|
||||
.block(Block::default().borders(Borders::ALL).title("Results"))
|
||||
.style(Style::default().fg(Color::Gray));
|
||||
f.render_widget(msg, chunks[3]);
|
||||
}
|
||||
|
||||
// Instructions
|
||||
let instructions = if app.search_results.is_some() {
|
||||
vec![
|
||||
Line::from(vec![
|
||||
Span::styled("j/k", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" navigate • "),
|
||||
Span::styled("t", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" switch category • "),
|
||||
Span::styled("Enter", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" open • "),
|
||||
Span::styled("Esc/q", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" back"),
|
||||
]),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
Line::from(vec![
|
||||
Span::raw("Type to search • "),
|
||||
Span::styled("Enter", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" to search • "),
|
||||
Span::styled("Esc/q", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" to go back"),
|
||||
]),
|
||||
]
|
||||
};
|
||||
|
||||
let help = Paragraph::new(instructions)
|
||||
.block(Block::default().borders(Borders::ALL).title("Instructions"))
|
||||
.style(Style::default().fg(Color::Gray));
|
||||
f.render_widget(help, chunks[4]);
|
||||
}
|
||||
|
||||
fn render_thread_view(f: &mut Frame, app: &App, area: Rect) {
|
||||
use crate::ui::content::ContentParser;
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3), // Header
|
||||
Constraint::Min(0), // Thread content
|
||||
Constraint::Length(3), // Instructions
|
||||
].as_ref())
|
||||
.split(area);
|
||||
|
||||
// Header
|
||||
let thread_info = if let Some(current_id) = &app.current_status_id {
|
||||
format!("Thread View ({})", current_id)
|
||||
} else {
|
||||
"Thread View".to_string()
|
||||
};
|
||||
|
||||
let header = Paragraph::new(thread_info)
|
||||
.block(Block::default().borders(Borders::ALL).title("Mastui"))
|
||||
.style(Style::default().fg(Color::Green));
|
||||
f.render_widget(header, chunks[0]);
|
||||
|
||||
// Thread content
|
||||
if app.thread_statuses.is_empty() {
|
||||
let empty_msg = if app.loading {
|
||||
"Loading thread..."
|
||||
} else {
|
||||
"No thread data"
|
||||
};
|
||||
|
||||
let paragraph = Paragraph::new(empty_msg)
|
||||
.block(Block::default().borders(Borders::ALL))
|
||||
.style(Style::default().fg(Color::Yellow));
|
||||
f.render_widget(paragraph, chunks[1]);
|
||||
} else {
|
||||
let items: Vec<ratatui::widgets::ListItem> = app.thread_statuses
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, status)| format_thread_status(status, i, app))
|
||||
.collect();
|
||||
|
||||
let mut list_state = ratatui::widgets::ListState::default();
|
||||
list_state.select(Some(app.selected_thread_index));
|
||||
|
||||
let list = ratatui::widgets::List::new(items)
|
||||
.block(Block::default().borders(Borders::ALL).title("Conversation"))
|
||||
.highlight_style(Style::default().bg(Color::DarkGray).add_modifier(ratatui::style::Modifier::BOLD))
|
||||
.highlight_symbol("▶ ");
|
||||
|
||||
f.render_stateful_widget(list, chunks[1], &mut list_state);
|
||||
}
|
||||
|
||||
// Instructions
|
||||
let instructions = vec![
|
||||
Line::from(vec![
|
||||
Span::styled("j/k", Style::default().fg(Color::Cyan)),
|
||||
Span::raw("/"),
|
||||
Span::styled("↑↓", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" navigate • "),
|
||||
Span::styled("r", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" reply • "),
|
||||
Span::styled("Enter", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" detail • "),
|
||||
Span::styled("Esc/q", Style::default().fg(Color::Cyan)),
|
||||
Span::raw(" back"),
|
||||
]),
|
||||
];
|
||||
|
||||
let help = Paragraph::new(instructions)
|
||||
.block(Block::default().borders(Borders::ALL).title("Controls"))
|
||||
.style(Style::default().fg(Color::Gray));
|
||||
f.render_widget(help, chunks[2]);
|
||||
}
|
||||
|
||||
fn format_thread_status(status: &crate::api::Status, _index: usize, app: &App) -> ratatui::widgets::ListItem<'static> {
|
||||
use crate::ui::content::ContentParser;
|
||||
|
||||
let mut lines = Vec::new();
|
||||
|
||||
// Determine the relationship and styling
|
||||
let (prefix, style) = if let Some(current_id) = &app.current_status_id {
|
||||
if status.id == *current_id {
|
||||
("● ", Style::default().fg(Color::Yellow).add_modifier(ratatui::style::Modifier::BOLD)) // Current post
|
||||
} else if let Some(context) = &app.thread_context {
|
||||
if context.ancestors.iter().any(|s| s.id == status.id) {
|
||||
("↑ ", Style::default().fg(Color::Blue)) // Ancestor
|
||||
} else {
|
||||
("↓ ", Style::default().fg(Color::Green)) // Descendant
|
||||
}
|
||||
} else {
|
||||
(" ", Style::default())
|
||||
}
|
||||
} else {
|
||||
(" ", Style::default())
|
||||
};
|
||||
|
||||
// User info - use owned strings
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(prefix.to_string(), style),
|
||||
Span::styled(
|
||||
format!("@{}", status.account.acct),
|
||||
Style::default().fg(Color::Cyan).add_modifier(ratatui::style::Modifier::BOLD),
|
||||
),
|
||||
Span::raw(" • ".to_string()),
|
||||
Span::styled(
|
||||
format_time(&status.created_at),
|
||||
Style::default().fg(Color::Gray),
|
||||
),
|
||||
]));
|
||||
|
||||
// Parse and display content
|
||||
let parser = ContentParser::new();
|
||||
let content_lines = parser.parse_content_simple(&status.content);
|
||||
|
||||
// Show first 2 lines of content
|
||||
for line in content_lines.iter().take(2) {
|
||||
// Convert to owned spans
|
||||
let owned_spans: Vec<_> = line.spans.iter()
|
||||
.map(|span| Span::styled(span.content.to_string(), span.style))
|
||||
.collect();
|
||||
lines.push(Line::from(owned_spans));
|
||||
}
|
||||
|
||||
if content_lines.len() > 2 {
|
||||
lines.push(Line::from(Span::styled("...".to_string(), Style::default().fg(Color::Gray))));
|
||||
}
|
||||
|
||||
// Add some spacing
|
||||
lines.push(Line::from("".to_string()));
|
||||
|
||||
ratatui::widgets::ListItem::new(ratatui::text::Text::from(lines))
|
||||
}
|
||||
|
||||
fn format_time(time: &chrono::DateTime<chrono::Utc>) -> String {
|
||||
|
|
Loading…
Reference in New Issue