/ src / ui / mod.rs
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  }