mod.rs
1 //! ratatui rendering. 2 //! 3 //! The UI is intentionally simple: 4 //! - Left pane: wrapped text content (scrollable) 5 //! - Right pane: list of discovered images 6 //! - Bottom: status/help line 7 8 use crate::app::AppState; 9 use ratatui::layout::{Constraint, Direction, Layout, Rect}; 10 use ratatui::style::{Modifier, Style}; 11 use ratatui::text::{Line, Text}; 12 use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; 13 use ratatui::Frame; 14 15 /// Compute the width for the right-hand image panel given terminal width. 16 pub fn compute_images_panel_width(term_width: u16) -> u16 { 17 let w = term_width as i32; 18 let min_images = 24; 19 let min_text = 30; 20 21 let mut img = (w / 3).max(min_images); 22 if img > (w - min_text) { 23 img = (w - min_text).max(10); 24 } 25 img.max(10) as u16 26 } 27 28 /// Approximate wrap width for the text pane given terminal width. 29 /// 30 /// Note: The definitive wrap width is computed inside `draw()` from actual layout. 31 pub fn compute_text_wrap_width(term_width: u16) -> usize { 32 let img = compute_images_panel_width(term_width); 33 let text_outer = term_width.saturating_sub(img); 34 text_outer.saturating_sub(2) as usize // borders 35 } 36 37 /// Draw the entire UI and update `app` with viewport sizing info. 38 pub fn draw(f: &mut Frame, app: &mut AppState) { 39 let area = f.size(); 40 41 let root = Layout::default() 42 .direction(Direction::Vertical) 43 .constraints([Constraint::Min(1), Constraint::Length(1)]) 44 .split(area); 45 46 let main = root[0]; 47 let status = root[1]; 48 49 // Split main into text + images. 50 let img_w = compute_images_panel_width(main.width); 51 let cols = Layout::default() 52 .direction(Direction::Horizontal) 53 .constraints([Constraint::Min(20), Constraint::Length(img_w)]) 54 .split(main); 55 56 let text_chunk = cols[0]; 57 let images_chunk = cols[1]; 58 59 // Compute inner sizes (minus borders). 60 let text_inner_w = text_chunk.width.saturating_sub(2) as usize; 61 let text_inner_h = text_chunk.height.saturating_sub(2) as usize; 62 63 app.ensure_wrap_width(text_inner_w); 64 app.set_viewport_height(text_inner_h); 65 66 draw_text(f, text_chunk, app); 67 draw_images(f, images_chunk, app); 68 draw_status(f, status, app); 69 } 70 71 fn draw_text(f: &mut Frame, area: Rect, app: &AppState) { 72 let title = format!("Text · {}", app.url); 73 let block = Block::default().title(title).borders(Borders::ALL); 74 75 // Build ratatui Text from our already-wrapped lines. 76 let lines: Vec<Line> = app 77 .text_lines 78 .iter() 79 .map(|l| Line::raw(l.clone())) 80 .collect(); 81 let text = Text::from(lines); 82 83 let para = Paragraph::new(text) 84 .block(block) 85 .scroll((app.scroll as u16, 0)); 86 87 f.render_widget(para, area); 88 } 89 90 fn draw_images(f: &mut Frame, area: Rect, app: &AppState) { 91 let block = Block::default() 92 .title("Images (manual)") 93 .borders(Borders::ALL); 94 95 let items: Vec<ListItem> = if app.images.is_empty() { 96 vec![ListItem::new("(no images found)")] 97 } else { 98 app.images 99 .iter() 100 .map(|img| { 101 let mut label = String::new(); 102 label.push_str(&format!("[{}] ", img.index)); 103 if img.alt.trim().is_empty() { 104 label.push_str("(no alt)"); 105 } else { 106 label.push_str(img.alt.trim()); 107 } 108 label.push_str(" · "); 109 label.push_str(img.url.as_str()); 110 111 if img.downloaded_path.is_some() { 112 label.push_str(" ✅"); 113 } 114 115 ListItem::new(label) 116 }) 117 .collect() 118 }; 119 120 let list = List::new(items) 121 .block(block) 122 .highlight_style(Style::default().add_modifier(Modifier::REVERSED)) 123 .highlight_symbol("▶ "); 124 125 let mut state = ListState::default(); 126 if !app.images.is_empty() { 127 state.select(Some(app.selected_image.min(app.images.len() - 1))); 128 } 129 130 f.render_stateful_widget(list, area, &mut state); 131 } 132 133 fn draw_status(f: &mut Frame, area: Rect, app: &AppState) { 134 let msg = app.status.clone(); 135 let para = Paragraph::new(msg); 136 f.render_widget(para, area); 137 }