ommp.git

ommp

~ Oh My Music Player — a standalone terminal music player built with Rust and ratatui
git clone https://github.com/debuman/ommp.git

src/ui/widgets/search_modal.rs (5639B)

      1 use ratatui::layout::{Constraint, Direction, Layout, Rect};
      2 use ratatui::style::{Color, Modifier, Style};
      3 use ratatui::text::{Line, Span};
      4 use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState};
      5 use ratatui::Frame;
      6 
      7 use crate::app::App;
      8 use crate::ui::theme::Theme;
      9 use crate::ui::text::{centered_rect, fit_to_width};
     10 
     11 const HOVER_BG: Color = Color::Indexed(238);
     12 
     13 #[allow(clippy::too_many_arguments)]
     14 pub fn render_search_modal(
     15     frame: &mut Frame,
     16     area: Rect,
     17     input: &str,
     18     results: &[usize],
     19     selected: usize,
     20     scroll: usize,
     21     hover_row: Option<usize>,
     22     app: &App,
     23     theme: &Theme,
     24 ) -> (usize, Rect) {
     25     let modal = centered_rect(50, 60, area);
     26 
     27     frame.render_widget(Clear, modal);
     28 
     29     let block = Block::default()
     30         .borders(Borders::ALL)
     31         .border_style(Style::default().fg(Color::Cyan))
     32         .title(" Search ")
     33         .title_style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD));
     34 
     35     let inner = block.inner(modal);
     36     frame.render_widget(block, modal);
     37 
     38     // Split inner: input(1) + separator(1) + results(rest)
     39     let chunks = Layout::default()
     40         .direction(Direction::Vertical)
     41         .constraints([
     42             Constraint::Length(1), // input line
     43             Constraint::Length(1), // separator
     44             Constraint::Min(1),   // results
     45         ])
     46         .split(inner);
     47 
     48     // Input line with cursor
     49     let input_line = Line::from(vec![
     50         Span::styled(" > ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
     51         Span::styled(input, Style::default().fg(Color::White)),
     52         Span::styled("_", Style::default().fg(Color::Cyan).add_modifier(Modifier::SLOW_BLINK)),
     53     ]);
     54     frame.render_widget(Paragraph::new(input_line), chunks[0]);
     55 
     56     // Separator
     57     let sep = "─".repeat(chunks[1].width as usize);
     58     frame.render_widget(
     59         Paragraph::new(Line::from(Span::styled(sep, Style::default().fg(Color::DarkGray)))),
     60         chunks[1],
     61     );
     62 
     63     // Results list. `title_bottom` on the list's block reserves a row even
     64     // though the block is borderless, so one fewer result is drawn than
     65     // chunks[2].height — reporting the full height back made keyboard
     66     // auto-scroll stop one short and mapped clicks on the count footer to a row
     67     // that isn't there.
     68     let result_height = (chunks[2].height as usize).saturating_sub(1);
     69     let result_area = Rect {
     70         height: result_height as u16,
     71         ..chunks[2]
     72     };
     73     let result_width = chunks[2].width as usize;
     74 
     75     if results.is_empty() {
     76         let msg = if input.is_empty() {
     77             "Type to search..."
     78         } else {
     79             "No results found"
     80         };
     81         frame.render_widget(
     82             Paragraph::new(Line::from(Span::styled(
     83                 format!("  {}", msg),
     84                 Style::default().fg(Color::DarkGray),
     85             ))),
     86             chunks[2],
     87         );
     88     } else {
     89         let items: Vec<ListItem> = results
     90             .iter()
     91             .enumerate()
     92             .skip(scroll)
     93             .take(result_height)
     94             .map(|(i, &track_idx)| {
     95                 let track = &app.library.tracks[track_idx];
     96                 let is_selected = i == selected;
     97                 let is_hovered = hover_row == Some(i);
     98 
     99                 let artist = track.display_artist();
    100                 let title_w = (result_width * 55 / 100).max(4);
    101                 let artist_w = result_width.saturating_sub(title_w + 3); // 3 = prefix + gap
    102 
    103                 let title_fitted = fit_to_width(&track.title, title_w);
    104                 let artist_fitted = fit_to_width(artist, artist_w);
    105 
    106                 let (style, artist_style) = if is_selected {
    107                     let s = Style::default()
    108                         .bg(theme.highlight_bg)
    109                         .fg(theme.highlight_fg)
    110                         .add_modifier(Modifier::BOLD);
    111                     (s, s)
    112                 } else if is_hovered {
    113                     (
    114                         Style::default().fg(theme.fg).bg(HOVER_BG),
    115                         Style::default().fg(Color::Gray).bg(HOVER_BG),
    116                     )
    117                 } else {
    118                     (
    119                         Style::default().fg(theme.fg),
    120                         Style::default().fg(Color::Gray),
    121                     )
    122                 };
    123 
    124                 let prefix = if is_selected { " > " } else { "   " };
    125 
    126                 ListItem::new(Line::from(vec![
    127                     Span::styled(prefix, style),
    128                     Span::styled(title_fitted, style),
    129                     Span::styled(artist_fitted, artist_style),
    130                 ]))
    131             })
    132             .collect();
    133 
    134         let count_info = format!(" {}/{} ", results.len(), app.library.tracks.len());
    135         let list = List::new(items).block(
    136             Block::default()
    137                 .title_bottom(Line::from(Span::styled(
    138                     count_info,
    139                     Style::default().fg(Color::DarkGray),
    140                 )))
    141         );
    142         frame.render_widget(list, chunks[2]);
    143 
    144         // Scrollbar
    145         if results.len() > result_height {
    146             let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
    147                 .begin_symbol(None)
    148                 .end_symbol(None);
    149             let mut scrollbar_state = ScrollbarState::new(results.len())
    150                 .position(scroll);
    151             frame.render_stateful_widget(
    152                 scrollbar,
    153                 chunks[2],
    154                 &mut scrollbar_state,
    155             );
    156         }
    157     }
    158 
    159     (result_height, result_area)
    160 }
    161