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