bbc_news_cli/
ui.rs

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),  // Header
21            Constraint::Min(0),     // Main content
22            Constraint::Length(1),  // Footer (no border)
23        ])
24        .split(f.area());
25
26    render_header(f, main_chunks[0], app);
27
28    // Full article view takes over the entire content area (full width)
29    if app.show_full_article {
30        render_full_article(f, main_chunks[1], app);
31    } else if app.show_preview {
32        // Split main content area if preview is enabled (but not full article)
33        let content_chunks = Layout::default()
34            .direction(Direction::Horizontal)
35            .constraints([
36                Constraint::Percentage(80),  // Story list
37                Constraint::Percentage(20),  // Preview
38            ])
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    // Render feed menu overlay if in feed menu mode
50    if app.mode == AppMode::FeedMenu {
51        render_feed_menu(f, app);
52    }
53
54    // Render help menu overlay if in help mode
55    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    // Add offline indicator if in offline mode
74    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    // Show error message if present
114    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            // Pad title to full width for full-width background, truncate if too long
148            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                // Truncate and add ellipsis
153                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                // Selected: white text on accent (BBC red) background (full width)
164                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                // Normal: primary text on primary background
173                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            // Metadata line (indented with 3 spaces) - always gray background
183            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                // Truncate and add ellipsis
193                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    // Create state for scrolling
219    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    // Split footer into left and right sections
229    let footer_chunks = Layout::default()
230        .direction(Direction::Horizontal)
231        .constraints([
232            Constraint::Min(0),      // Left: status/ticker
233            Constraint::Length(8),   // Right: time (HH:MM:SS)
234        ])
235        .split(area);
236
237    // Left side: show refresh status or ticker/keybindings
238    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        // Show default keybindings help when no ticker stories
250        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    // Right side: current time with seconds
258    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        // Get inner area (within borders)
276        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            // Create paragraph with full text and let ratatui handle wrapping
281            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            // No article content cached - show error message
290            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        // Choose title based on loading state
302        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        // Get inner area (within borders)
315        let inner_area = preview_block.inner(area);
316        f.render_widget(preview_block, area);
317
318        // LOADING STATE: Show loading message
319        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        // Check if area is too small for preview
329        let min_width = 20;
330        let min_height = 6;
331
332        if area.width < min_width || area.height < min_height {
333            // Too small - show message instead
334            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        // Split into image area and text area
343        let chunks = Layout::default()
344            .direction(Direction::Vertical)
345            .constraints([
346                Constraint::Percentage(40), // Image area
347                Constraint::Percentage(60), // Text area
348            ])
349            .split(inner_area);
350
351        // Render image
352        let img = get_image(story.image_url.as_deref());
353
354        // Get image area for rendering
355        let image_area = chunks[0];
356
357        // Create picker and configure protocol
358        let mut picker = Picker::new((8, 12));
359
360        // Set protocol based on user preference
361        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        // Calculate target pixel dimensions based on widget area and font size
377        // This ensures the image fits within the allocated space while maintaining aspect ratio
378        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        // Resize image to fit within the widget area while maintaining aspect ratio
383        let resized_img = img.resize(target_width, target_height, image::imageops::FilterType::Triangle);
384
385        // Create protocol from resized image
386        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        // Create text content
391        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        // Add description
402        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        // Add metadata
411        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    // Create centered popup
438    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    // Clear the area
450    f.render_widget(Clear, popup_area);
451
452    // Create feed items
453    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    // Create centered popup
493    let area = f.area();
494    let popup_width = 70.min(area.width - 4);
495    let popup_height = 22.min(area.height - 4);  // Enough for all help items + padding
496
497    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    // Clear the area
505    f.render_widget(Clear, popup_area);
506
507    // Create help content
508    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}