src/ui/panes/list_pane.rs (11072B)
1 //! One scrollable list of labelled rows, shared by the Artists, Albums, Genre,
2 //! Format and Playlists tabs.
3 //!
4 //! These were five near-identical files; normalising identifiers made three of
5 //! them byte-for-byte the same over all 185 lines, and the other two differed
6 //! only inside the row-render closure and one call in the Enter arm. Everything
7 //! here beyond `ListRow` and `rows_for`/`activate` was copied five ways, which
8 //! is how the render-time selection clamp ended up in exactly one of them.
9
10 use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
11 use ratatui::layout::{Margin, Rect};
12 use ratatui::style::{Color, Modifier, Style};
13 use ratatui::text::{Line, Span};
14 use ratatui::widgets::{
15 Block, Borders, List, ListItem, Scrollbar, ScrollbarOrientation, ScrollbarState,
16 };
17 use ratatui::Frame;
18
19 use crate::app::state::Tab;
20 use crate::app::{App, AppAction};
21 use crate::ui::text::{fit_to_width, max_scroll};
22 use crate::ui::theme::Theme;
23
24 const HOVER_BG: Color = Color::Indexed(238);
25 const SCROLL_STEP: usize = 3;
26
27 /// A row: an optional leading icon, the label, and dim trailing detail such as
28 /// an album's artist or a track count.
29 pub struct ListRow {
30 pub icon: Option<(&'static str, Color)>,
31 pub text: String,
32 pub detail: Option<String>,
33 }
34
35 impl ListRow {
36 pub fn plain(text: String) -> Self {
37 Self { icon: None, text, detail: None }
38 }
39
40 pub fn with_detail(text: String, detail: String) -> Self {
41 Self { icon: None, text, detail: Some(detail) }
42 }
43 }
44
45 #[derive(Default)]
46 pub struct ListPane {
47 pub selected: usize,
48 pub scroll_offset: usize,
49 pub hover_row: Option<usize>,
50 }
51
52 impl ListPane {
53 pub fn render(
54 &mut self,
55 frame: &mut Frame,
56 area: Rect,
57 focused: bool,
58 theme: &Theme,
59 rows: &[ListRow],
60 ) {
61 let count = rows.len();
62 // Clamp here rather than trusting callers: only one of the five copies
63 // did this, and it survived purely because a separate clamp ran on
64 // library reload.
65 if self.selected >= count {
66 self.selected = count.saturating_sub(1);
67 }
68
69 let border_color = if focused {
70 theme.border_focused
71 } else {
72 theme.border_unfocused
73 };
74 let block = Block::default()
75 .borders(Borders::ALL)
76 .border_style(Style::default().fg(border_color));
77
78 let inner = block.inner(area);
79 let inner_height = inner.height as usize;
80 let inner_width = inner.width as usize;
81
82 // Auto-scroll to keep the selection visible, then make sure the viewport
83 // is still full.
84 if count > 0 && inner_height > 0 {
85 if self.selected < self.scroll_offset {
86 self.scroll_offset = self.selected;
87 }
88 if self.selected >= self.scroll_offset + inner_height {
89 self.scroll_offset = self.selected - inner_height + 1;
90 }
91 self.scroll_offset = self.scroll_offset.min(max_scroll(count, inner_height));
92 }
93
94 let highlight = Style::default()
95 .bg(theme.highlight_bg)
96 .fg(theme.highlight_fg)
97 .add_modifier(Modifier::BOLD);
98
99 let items: Vec<ListItem> = rows
100 .iter()
101 .enumerate()
102 .skip(self.scroll_offset)
103 .take(inner_height)
104 .map(|(i, row)| {
105 let selected = i == self.selected && focused;
106 let hovered = self.hover_row == Some(i);
107 let bg = if hovered { HOVER_BG } else { Color::Reset };
108
109 let base = if selected {
110 highlight
111 } else {
112 Style::default().fg(theme.fg).bg(bg)
113 };
114 let dim = if selected {
115 highlight
116 } else {
117 Style::default().fg(Color::DarkGray).bg(bg)
118 };
119
120 let mut spans = Vec::new();
121 let mut used = 2;
122 spans.push(Span::styled(" ", base));
123 if let Some((icon, color)) = row.icon {
124 used += icon.chars().count() + 1;
125 let style = if selected {
126 highlight
127 } else {
128 Style::default().fg(color).bg(bg)
129 };
130 spans.push(Span::styled(format!("{icon} "), style));
131 }
132
133 match &row.detail {
134 // Pad the label to a fixed column so the detail lines up on
135 // every row. Without this a CJK label and an ASCII one put
136 // the second column at different offsets.
137 Some(detail) => {
138 let detail_w = detail.chars().count() + 1;
139 let label_w = inner_width.saturating_sub(used + detail_w);
140 spans.push(Span::styled(fit_to_width(&row.text, label_w), base));
141 spans.push(Span::styled(format!(" {detail}"), dim));
142 }
143 None => spans.push(Span::styled(
144 fit_to_width(&row.text, inner_width.saturating_sub(used)),
145 base,
146 )),
147 }
148
149 ListItem::new(Line::from(spans))
150 })
151 .collect();
152
153 frame.render_widget(List::new(items).block(block), area);
154
155 if count > inner_height {
156 let mut state = ScrollbarState::new(count).position(self.scroll_offset);
157 frame.render_stateful_widget(
158 Scrollbar::new(ScrollbarOrientation::VerticalRight)
159 .begin_symbol(None)
160 .end_symbol(None),
161 area.inner(Margin { vertical: 1, horizontal: 0 }),
162 &mut state,
163 );
164 }
165 }
166
167 /// Returns true when the key selected an item and the caller should build an
168 /// activation action.
169 pub fn handle_key(&mut self, key: KeyEvent, count: usize) -> bool {
170 if count == 0 {
171 return false;
172 }
173 match key.code {
174 KeyCode::Char('j') | KeyCode::Down => {
175 if self.selected < count - 1 {
176 self.selected += 1;
177 }
178 }
179 KeyCode::Char('k') | KeyCode::Up => {
180 self.selected = self.selected.saturating_sub(1);
181 }
182 KeyCode::Home | KeyCode::Char('g') => {
183 self.selected = 0;
184 self.scroll_offset = 0;
185 }
186 KeyCode::End | KeyCode::Char('G') => {
187 self.selected = count - 1;
188 }
189 KeyCode::Enter => return true,
190 _ => {}
191 }
192 false
193 }
194
195 pub fn handle_mouse(&mut self, event: MouseEvent, area: Rect, count: usize) {
196 match event.kind {
197 MouseEventKind::Down(MouseButton::Left) => {
198 let inner = Block::default().borders(Borders::ALL).inner(area);
199 if event.column >= inner.x
200 && event.column < inner.x + inner.width
201 && event.row >= inner.y
202 && event.row < inner.y + inner.height
203 {
204 let clicked = self.scroll_offset + (event.row - inner.y) as usize;
205 if clicked < count {
206 self.selected = clicked;
207 }
208 }
209 }
210 MouseEventKind::ScrollDown => self.scroll(false, count, area.height as usize),
211 MouseEventKind::ScrollUp => self.scroll(true, count, area.height as usize),
212 _ => {}
213 }
214 }
215
216 pub fn scroll(&mut self, up: bool, count: usize, viewport: usize) {
217 if count == 0 {
218 return;
219 }
220 // Two border rows.
221 let inner_height = viewport.saturating_sub(2);
222 if up {
223 self.scroll_offset = self.scroll_offset.saturating_sub(SCROLL_STEP);
224 self.selected = self.selected.saturating_sub(SCROLL_STEP);
225 } else {
226 self.scroll_offset =
227 (self.scroll_offset + SCROLL_STEP).min(max_scroll(count, inner_height));
228 self.selected = (self.selected + SCROLL_STEP).min(count - 1);
229 }
230 }
231 }
232
233 /// How many rows `tab` will produce, without building any of them.
234 ///
235 /// Mouse handling needs only the count; it used to get it by materialising the
236 /// whole row list, so every motion event rebuilt the artist or album view.
237 pub fn row_count(app: &App, tab: Tab) -> usize {
238 match tab {
239 Tab::Artists => app.library.artists().len(),
240 Tab::Albums => app.library.albums().len(),
241 Tab::Genre => app.library.genres().len(),
242 Tab::Format => app.library.formats().len(),
243 Tab::Playlists => app.playlists.len(),
244 Tab::Queue | Tab::Directories => 0,
245 }
246 }
247
248 /// Rows for whichever of the five list tabs is showing.
249 pub fn rows_for(app: &App, tab: Tab) -> Vec<ListRow> {
250 match tab {
251 Tab::Artists => app
252 .library
253 .artists()
254 .iter()
255 .map(|a| ListRow::plain(a.clone()))
256 .collect(),
257 Tab::Albums => app
258 .library
259 .albums()
260 .iter()
261 .map(|(album, artist)| ListRow::with_detail(album.clone(), artist.clone()))
262 .collect(),
263 Tab::Genre => app
264 .library
265 .genres()
266 .iter()
267 .map(|g| ListRow::plain(g.clone()))
268 .collect(),
269 Tab::Format => app
270 .library
271 .formats()
272 .iter()
273 .map(|(fmt, n)| ListRow::with_detail(fmt.clone(), format!("({n})")))
274 .collect(),
275 Tab::Playlists => app
276 .playlists
277 .iter()
278 .map(|pl| ListRow {
279 icon: Some(("\u{F005}", Color::Yellow)),
280 text: pl.name.clone(),
281 detail: Some(format!("({})", pl.tracks.len())),
282 })
283 .collect(),
284 Tab::Queue | Tab::Directories => Vec::new(),
285 }
286 }
287
288 /// The action for activating row `selected` of `tab`.
289 pub fn activate(app: &App, tab: Tab, selected: usize) -> Option<AppAction> {
290 let tracks = match tab {
291 Tab::Artists => app.library.tracks_by_artist(app.library.artists().get(selected)?).to_vec(),
292 Tab::Albums => {
293 let (album, artist) = app.library.albums().get(selected)?;
294 app.library.tracks_by_album(album, artist).to_vec()
295 }
296 Tab::Genre => app.library.tracks_by_genre(app.library.genres().get(selected)?).to_vec(),
297 Tab::Format => app
298 .library
299 .tracks_by_format(&app.library.formats().get(selected)?.0)
300 .to_vec(),
301 Tab::Playlists => app.playlists.get(selected)?.tracks.clone(),
302 Tab::Queue | Tab::Directories => return None,
303 };
304 (!tracks.is_empty()).then_some(AppAction::AddToQueue(tracks))
305 }