1use ratatui::{
2 layout::{Alignment, Constraint, Direction, Layout, Rect},
3 style::{Color, Modifier, Style},
4 text::{Line, Span},
5 widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Clear},
6 Frame,
7};
8use ratatui_image::{picker::Picker, StatefulImage};
9
10use crate::app::{App, AppMode, ImageProtocol};
11use crate::date_utils::humanize_time;
12use crate::feeds::get_all_feeds;
13use crate::image_cache::get_image;
14use ratatui_image::picker::ProtocolType;
15
16pub fn render(f: &mut Frame, app: &App) {
17 let main_chunks = Layout::default()
18 .direction(Direction::Vertical)
19 .constraints([
20 Constraint::Length(3), Constraint::Min(0), Constraint::Length(1), ])
24 .split(f.area());
25
26 render_header(f, main_chunks[0], app);
27
28 if app.show_full_article {
30 render_full_article(f, main_chunks[1], app);
31 } else if app.show_preview {
32 let content_chunks = Layout::default()
34 .direction(Direction::Horizontal)
35 .constraints([
36 Constraint::Percentage(80), Constraint::Percentage(20), ])
39 .split(main_chunks[1]);
40
41 render_stories(f, content_chunks[0], app);
42 render_preview(f, content_chunks[1], app);
43 } else {
44 render_stories(f, main_chunks[1], app);
45 }
46
47 render_footer(f, main_chunks[2], app);
48
49 if app.mode == AppMode::FeedMenu {
51 render_feed_menu(f, app);
52 }
53
54 if app.mode == AppMode::Help {
56 render_help_menu(f, app);
57 }
58}
59
60fn render_header(f: &mut Frame, area: Rect, app: &App) {
61 let last_updated = if !app.stories.is_empty() {
62 let date_str = app.stories.first().map(|s| s.pub_date.as_str()).unwrap_or("");
63 let formatted_date = if app.humanize_dates {
64 humanize_time(date_str)
65 } else {
66 date_str.to_string()
67 };
68 format!("Last updated: {} | {}", formatted_date, app.current_feed.name)
69 } else {
70 format!("Last updated: -- | {}", app.current_feed.name)
71 };
72
73 let title_text = if app.is_offline {
75 "BBC | NEWS [OFFLINE]"
76 } else {
77 "BBC | NEWS"
78 };
79
80 let header_text = vec![
81 Line::from(
82 Span::styled(
83 title_text,
84 Style::default().fg(app.theme.accent_fg).add_modifier(Modifier::BOLD)
85 )
86 ),
87 Line::from(
88 Span::styled(
89 last_updated,
90 Style::default().fg(app.theme.accent_fg)
91 )
92 ),
93 ];
94
95 let header = Paragraph::new(header_text)
96 .alignment(Alignment::Center)
97 .block(Block::default()
98 .borders(Borders::NONE)
99 .style(Style::default().bg(app.theme.accent)));
100
101 f.render_widget(header, area);
102}
103
104fn render_stories(f: &mut Frame, area: Rect, app: &App) {
105 if app.is_loading {
106 let loading = Paragraph::new("Loading BBC News...")
107 .style(Style::default().fg(app.theme.fg_primary).bg(app.theme.bg_primary))
108 .alignment(Alignment::Center);
109 f.render_widget(loading, area);
110 return;
111 }
112
113 if let Some(ref error_msg) = app.error_message {
115 let error_text = vec![
116 Line::from(Span::styled("Error fetching BBC News:", Style::default().fg(Color::Red).bg(app.theme.bg_primary).add_modifier(Modifier::BOLD))),
117 Line::from(""),
118 Line::from(Span::styled(error_msg, Style::default().fg(app.theme.fg_primary).bg(app.theme.bg_primary))),
119 Line::from(""),
120 Line::from(Span::styled("Press 'r' to retry", Style::default().fg(app.theme.fg_secondary).bg(app.theme.bg_primary))),
121 ];
122 let error = Paragraph::new(error_text)
123 .style(Style::default().bg(app.theme.bg_primary))
124 .alignment(Alignment::Center);
125 f.render_widget(error, area);
126 return;
127 }
128
129 if app.stories.is_empty() {
130 let empty = Paragraph::new("No stories available. Press 'r' to refresh.")
131 .style(Style::default().fg(Color::Red).bg(app.theme.bg_primary))
132 .alignment(Alignment::Center);
133 f.render_widget(empty, area);
134 return;
135 }
136
137 let items: Vec<ListItem> = app
138 .stories
139 .iter()
140 .enumerate()
141 .map(|(i, story)| {
142 let is_selected = i == app.selected;
143 let number = i + 1;
144
145 let title_text = format!("{}. {}", number, story.title);
146
147 let width = area.width as usize;
149 let padded_title = if title_text.len() < width {
150 format!("{}{}", title_text, " ".repeat(width - title_text.len()))
151 } else {
152 let max_len = width.saturating_sub(3);
154 if max_len > 0 {
155 let truncated = title_text.chars().take(max_len).collect::<String>();
156 format!("{}...", truncated)
157 } else {
158 title_text.chars().take(width).collect::<String>()
159 }
160 };
161
162 let title_line = if is_selected {
163 Line::styled(
165 padded_title,
166 Style::default()
167 .fg(app.theme.accent_fg)
168 .bg(app.theme.accent)
169 .add_modifier(Modifier::BOLD)
170 )
171 } else {
172 Line::styled(
174 padded_title,
175 Style::default()
176 .fg(app.theme.fg_primary)
177 .bg(app.theme.bg_primary)
178 .add_modifier(Modifier::BOLD)
179 )
180 };
181
182 let formatted_date = if app.humanize_dates {
184 humanize_time(&story.pub_date)
185 } else {
186 story.pub_date.clone()
187 };
188 let meta_text = format!(" Last updated: {} | {}", formatted_date, app.current_feed.name);
189 let padded_meta = if meta_text.len() < width {
190 format!("{}{}", meta_text, " ".repeat(width - meta_text.len()))
191 } else {
192 let max_len = width.saturating_sub(3);
194 if max_len > 0 {
195 let truncated = meta_text.chars().take(max_len).collect::<String>();
196 format!("{}...", truncated)
197 } else {
198 meta_text.chars().take(width).collect::<String>()
199 }
200 };
201
202 let meta_line = Line::styled(
203 padded_meta,
204 Style::default()
205 .fg(app.theme.fg_secondary)
206 .bg(app.theme.bg_primary)
207 );
208
209 ListItem::new(vec![title_line, meta_line])
210 })
211 .collect();
212
213 let list = List::new(items)
214 .block(Block::default()
215 .borders(Borders::NONE)
216 .style(Style::default().bg(app.theme.bg_primary)));
217
218 let mut state = ListState::default();
220 state.select(Some(app.selected));
221
222 f.render_stateful_widget(list, area, &mut state);
223}
224
225fn render_footer(f: &mut Frame, area: Rect, app: &App) {
226 use chrono::Local;
227
228 let footer_chunks = Layout::default()
230 .direction(Direction::Horizontal)
231 .constraints([
232 Constraint::Min(0), Constraint::Length(8), ])
235 .split(area);
236
237 let footer_text = if app.is_refreshing {
239 String::from("Refreshing News...")
240 } else if !app.ticker_stories.is_empty() {
241 let max_ticker_items = 8.min(app.ticker_stories.len());
242 if app.ticker_index < app.ticker_stories.len() {
243 let story = &app.ticker_stories[app.ticker_index];
244 format!("[LATEST] {} ({}/{})", story.title, app.ticker_index + 1, max_ticker_items)
245 } else {
246 format!("q: quit | o: open | Tab: preview | f: feeds | s: sort ({}) | t: dates | p: protocol ({}) | r: refresh", app.sort_order.name(), app.image_protocol.name())
247 }
248 } else {
249 format!("q: quit | o: open | Tab: preview | f: feeds | s: sort ({}) | t: dates | p: protocol ({}) | r: refresh", app.sort_order.name(), app.image_protocol.name())
251 };
252
253 let footer_left = Paragraph::new(footer_text)
254 .style(Style::default().fg(app.theme.fg_primary).bg(app.theme.bg_accent))
255 .alignment(Alignment::Left);
256
257 let current_time = Local::now().format("%H:%M:%S").to_string();
259 let footer_right = Paragraph::new(current_time)
260 .style(Style::default().fg(app.theme.fg_primary).bg(app.theme.bg_accent))
261 .alignment(Alignment::Right);
262
263 f.render_widget(footer_left, footer_chunks[0]);
264 f.render_widget(footer_right, footer_chunks[1]);
265}
266
267fn render_full_article(f: &mut Frame, area: Rect, app: &App) {
268 if let Some(_story) = app.stories.get(app.selected) {
269 let article_block = Block::default()
270 .title("Article View (Enter/Tab/Esc to close)")
271 .borders(Borders::ALL)
272 .border_style(Style::default().fg(app.theme.accent))
273 .style(Style::default().bg(app.theme.bg_primary));
274
275 let inner_area = article_block.inner(area);
277 f.render_widget(article_block, area);
278
279 if let Some(article_text) = app.get_current_article_text() {
280 let article_paragraph = Paragraph::new(article_text.as_str())
282 .wrap(ratatui::widgets::Wrap { trim: true })
283 .alignment(Alignment::Left)
284 .style(Style::default().fg(app.theme.fg_primary).bg(app.theme.bg_primary))
285 .scroll((app.article_scroll_offset as u16, 0));
286
287 f.render_widget(article_paragraph, inner_area);
288 } else {
289 let error_msg = Paragraph::new("No article content available.\nPress Tab or Esc to return to preview.")
291 .style(Style::default().fg(Color::Red).bg(app.theme.bg_primary))
292 .alignment(Alignment::Center)
293 .wrap(ratatui::widgets::Wrap { trim: true });
294 f.render_widget(error_msg, inner_area);
295 }
296 }
297}
298
299fn render_preview(f: &mut Frame, area: Rect, app: &App) {
300 if let Some(story) = app.stories.get(app.selected) {
301 let title = if app.is_fetching_article {
303 "Loading Article..."
304 } else {
305 "Preview (Tab/Enter for article)"
306 };
307
308 let preview_block = Block::default()
309 .title(title)
310 .borders(Borders::ALL)
311 .border_style(Style::default().fg(app.theme.accent))
312 .style(Style::default().bg(app.theme.bg_primary));
313
314 let inner_area = preview_block.inner(area);
316 f.render_widget(preview_block, area);
317
318 if app.is_fetching_article {
320 let loading_msg = Paragraph::new("Fetching article...\nThis may take a few seconds.")
321 .style(Style::default().fg(app.theme.fg_secondary).bg(app.theme.bg_primary))
322 .alignment(Alignment::Center)
323 .wrap(ratatui::widgets::Wrap { trim: true });
324 f.render_widget(loading_msg, inner_area);
325 return;
326 }
327
328 let min_width = 20;
330 let min_height = 6;
331
332 if area.width < min_width || area.height < min_height {
333 let msg = Paragraph::new("Terminal too small for preview")
335 .style(Style::default().fg(app.theme.fg_secondary).bg(app.theme.bg_primary))
336 .alignment(Alignment::Center)
337 .wrap(ratatui::widgets::Wrap { trim: true });
338 f.render_widget(msg, inner_area);
339 return;
340 }
341
342 let chunks = Layout::default()
344 .direction(Direction::Vertical)
345 .constraints([
346 Constraint::Percentage(40), Constraint::Percentage(60), ])
349 .split(inner_area);
350
351 let img = get_image(story.image_url.as_deref());
353
354 let image_area = chunks[0];
356
357 let mut picker = Picker::new((8, 12));
359
360 match app.image_protocol {
362 ImageProtocol::Auto => {
363 picker.guess_protocol();
364 },
365 ImageProtocol::Halfblocks => {
366 picker.protocol_type = ProtocolType::Halfblocks;
367 },
368 ImageProtocol::Sixel => {
369 picker.protocol_type = ProtocolType::Sixel;
370 },
371 ImageProtocol::Kitty => {
372 picker.protocol_type = ProtocolType::Kitty;
373 },
374 }
375
376 let font_size = picker.font_size;
379 let target_width = (image_area.width as u32 * font_size.0 as u32) as u32;
380 let target_height = (image_area.height as u32 * font_size.1 as u32) as u32;
381
382 let resized_img = img.resize(target_width, target_height, image::imageops::FilterType::Triangle);
384
385 let mut dyn_img = picker.new_resize_protocol(resized_img);
387 let image_widget = StatefulImage::new(None);
388 f.render_stateful_widget(image_widget, image_area, &mut dyn_img);
389
390 let mut preview_lines = vec![
392 Line::from(Span::styled(
393 &story.title,
394 Style::default()
395 .fg(app.theme.fg_primary)
396 .add_modifier(Modifier::BOLD)
397 )),
398 Line::from(""),
399 ];
400
401 if !story.description.is_empty() {
403 preview_lines.push(Line::from(Span::styled(
404 &story.description,
405 Style::default().fg(app.theme.fg_primary)
406 )));
407 preview_lines.push(Line::from(""));
408 }
409
410 let formatted_date = if app.humanize_dates {
412 humanize_time(&story.pub_date)
413 } else {
414 story.pub_date.clone()
415 };
416 preview_lines.push(Line::from(Span::styled(
417 format!("Published: {}", formatted_date),
418 Style::default().fg(app.theme.fg_secondary)
419 )));
420 preview_lines.push(Line::from(Span::styled(
421 format!("Feed: {}", app.current_feed.name),
422 Style::default().fg(app.theme.fg_secondary)
423 )));
424
425 let preview_text = Paragraph::new(preview_lines)
426 .wrap(ratatui::widgets::Wrap { trim: true })
427 .alignment(Alignment::Left)
428 .style(Style::default().bg(app.theme.bg_primary));
429
430 f.render_widget(preview_text, chunks[1]);
431 }
432}
433
434fn render_feed_menu(f: &mut Frame, app: &App) {
435 let feeds = get_all_feeds();
436
437 let area = f.area();
439 let popup_width = 60.min(area.width - 4);
440 let popup_height = (feeds.len() as u16 + 4).min(area.height - 4);
441
442 let popup_area = Rect {
443 x: (area.width.saturating_sub(popup_width)) / 2,
444 y: (area.height.saturating_sub(popup_height)) / 2,
445 width: popup_width,
446 height: popup_height,
447 };
448
449 f.render_widget(Clear, popup_area);
451
452 let items: Vec<ListItem> = feeds
454 .iter()
455 .enumerate()
456 .map(|(i, feed)| {
457 let is_selected = i == app.feed_menu_selected;
458 let is_current = feed.name == app.current_feed.name;
459
460 let indicator = if is_current { "✓ " } else { " " };
461 let text = format!("{}{}", indicator, feed.name);
462
463 let style = if is_selected {
464 Style::default()
465 .fg(app.theme.accent_fg)
466 .bg(app.theme.accent)
467 .add_modifier(Modifier::BOLD)
468 } else {
469 Style::default()
470 .fg(app.theme.fg_primary)
471 .bg(app.theme.bg_primary)
472 };
473
474 ListItem::new(Line::styled(text, style))
475 })
476 .collect();
477
478 let list = List::new(items)
479 .block(Block::default()
480 .title("Select Feed (f/Esc to close, Enter to select)")
481 .borders(Borders::ALL)
482 .border_style(Style::default().fg(app.theme.accent))
483 .style(Style::default().bg(app.theme.bg_primary)));
484
485 let mut state = ListState::default();
486 state.select(Some(app.feed_menu_selected));
487
488 f.render_stateful_widget(list, popup_area, &mut state);
489}
490
491fn render_help_menu(f: &mut Frame, app: &App) {
492 let area = f.area();
494 let popup_width = 70.min(area.width - 4);
495 let popup_height = 22.min(area.height - 4); let popup_area = Rect {
498 x: (area.width.saturating_sub(popup_width)) / 2,
499 y: (area.height.saturating_sub(popup_height)) / 2,
500 width: popup_width,
501 height: popup_height,
502 };
503
504 f.render_widget(Clear, popup_area);
506
507 let help_text = vec![
509 Line::from(Span::styled(
510 "Navigation",
511 Style::default().fg(app.theme.accent).add_modifier(Modifier::BOLD)
512 )),
513 Line::from(Span::styled(
514 " j / ↓ Scroll down",
515 Style::default().fg(app.theme.fg_primary)
516 )),
517 Line::from(Span::styled(
518 " k / ↑ Scroll up",
519 Style::default().fg(app.theme.fg_primary)
520 )),
521 Line::from(Span::styled(
522 " G Jump to bottom",
523 Style::default().fg(app.theme.fg_primary)
524 )),
525 Line::from(Span::styled(
526 " l Jump to top",
527 Style::default().fg(app.theme.fg_primary)
528 )),
529 Line::from(""),
530 Line::from(Span::styled(
531 "Actions",
532 Style::default().fg(app.theme.accent).add_modifier(Modifier::BOLD)
533 )),
534 Line::from(Span::styled(
535 " o Open article in browser",
536 Style::default().fg(app.theme.fg_primary)
537 )),
538 Line::from(Span::styled(
539 " O Open in new tab",
540 Style::default().fg(app.theme.fg_primary)
541 )),
542 Line::from(Span::styled(
543 " Space Jump to ticker article",
544 Style::default().fg(app.theme.fg_primary)
545 )),
546 Line::from(Span::styled(
547 " r Refresh news feed",
548 Style::default().fg(app.theme.fg_primary)
549 )),
550 Line::from(""),
551 Line::from(Span::styled(
552 "Views",
553 Style::default().fg(app.theme.accent).add_modifier(Modifier::BOLD)
554 )),
555 Line::from(Span::styled(
556 " Tab Toggle preview pane",
557 Style::default().fg(app.theme.fg_primary)
558 )),
559 Line::from(Span::styled(
560 " a / Enter Open article view",
561 Style::default().fg(app.theme.fg_primary)
562 )),
563 Line::from(Span::styled(
564 " f Open feed selector",
565 Style::default().fg(app.theme.fg_primary)
566 )),
567 Line::from(""),
568 Line::from(Span::styled(
569 "Settings",
570 Style::default().fg(app.theme.accent).add_modifier(Modifier::BOLD)
571 )),
572 Line::from(Span::styled(
573 " s Cycle sort order",
574 Style::default().fg(app.theme.fg_primary)
575 )),
576 Line::from(Span::styled(
577 " t Toggle date format",
578 Style::default().fg(app.theme.fg_primary)
579 )),
580 Line::from(Span::styled(
581 " T Cycle theme (light/dark)",
582 Style::default().fg(app.theme.fg_primary)
583 )),
584 Line::from(Span::styled(
585 " p Cycle image protocol",
586 Style::default().fg(app.theme.fg_primary)
587 )),
588 Line::from(""),
589 Line::from(Span::styled(
590 "Other",
591 Style::default().fg(app.theme.accent).add_modifier(Modifier::BOLD)
592 )),
593 Line::from(Span::styled(
594 " q / Esc Quit",
595 Style::default().fg(app.theme.fg_primary)
596 )),
597 Line::from(Span::styled(
598 " ? Toggle this help menu",
599 Style::default().fg(app.theme.fg_primary)
600 )),
601 ];
602
603 let help_paragraph = Paragraph::new(help_text)
604 .block(Block::default()
605 .title("Help (? or Esc to close)")
606 .borders(Borders::ALL)
607 .border_style(Style::default().fg(app.theme.accent))
608 .style(Style::default().bg(app.theme.bg_primary)))
609 .alignment(Alignment::Left);
610
611 f.render_widget(help_paragraph, popup_area);
612}