src/ui/panes/dir_browser_pane.rs (10188B)
1 use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
2 use ratatui::layout::Rect;
3 use ratatui::style::{Color, Modifier, Style};
4 use ratatui::text::{Line, Span};
5 use ratatui::widgets::{Block, Borders, List, ListItem, Scrollbar, ScrollbarOrientation, ScrollbarState};
6 use ratatui::Frame;
7 use std::path::PathBuf;
8
9 use crate::app::{App, AppAction};
10 use crate::ui::pane::Pane;
11 use crate::ui::theme::Theme;
12 use crate::ui::text::max_scroll;
13
14 const HOVER_BG: Color = Color::Indexed(238);
15
16 pub struct DirBrowserPane {
17 pub current_dir: PathBuf,
18 pub entries: Vec<DirEntry>,
19 pub selected: usize,
20 pub scroll_offset: usize,
21 pub hover_row: Option<usize>,
22 }
23
24 #[derive(Debug, Clone)]
25 pub enum DirEntry {
26 ParentDir,
27 Directory(String),
28 Track(usize),
29 }
30
31 impl DirBrowserPane {
32 pub fn new(music_dir: PathBuf) -> Self {
33 Self {
34 current_dir: music_dir,
35 entries: Vec::new(),
36 selected: 0,
37 scroll_offset: 0,
38 hover_row: None,
39 }
40 }
41
42 pub fn refresh(&mut self, app: &App) {
43 self.entries.clear();
44
45 if self.current_dir != app.music_dir {
46 self.entries.push(DirEntry::ParentDir);
47 }
48
49 let (subdirs, tracks) = app.library.get_directory_entries(&self.current_dir);
50 for dir in subdirs {
51 self.entries.push(DirEntry::Directory(dir));
52 }
53 for track_idx in tracks {
54 self.entries.push(DirEntry::Track(track_idx));
55 }
56 }
57 }
58
59 impl Pane for DirBrowserPane {
60 fn render(&mut self, frame: &mut Frame, area: Rect, focused: bool, app: &App, theme: &Theme) {
61 let count = self.entries.len();
62 let border_color = if focused {
63 theme.border_focused
64 } else {
65 theme.border_unfocused
66 };
67
68 let block = Block::default()
69 .borders(Borders::ALL)
70 .border_style(Style::default().fg(border_color))
71 .title_style(Style::default().fg(if focused {
72 theme.border_focused
73 } else {
74 theme.fg
75 }));
76
77 let inner = block.inner(area);
78 let inner_height = inner.height as usize;
79
80 // Auto-scroll
81 if count > 0 {
82 if self.selected < self.scroll_offset {
83 self.scroll_offset = self.selected;
84 }
85 if inner_height > 0 && self.selected >= self.scroll_offset + inner_height {
86 self.scroll_offset = self.selected - inner_height + 1;
87 }
88 // Keep the viewport full: the scroll handlers clamp to count - 1,
89 // which lets the list scroll until one row sits above a blank screen.
90 self.scroll_offset = self.scroll_offset.min(max_scroll(count, inner_height));
91 }
92
93 let has_scrollbar = count > inner_height;
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> = self
100 .entries
101 .iter()
102 .enumerate()
103 .skip(self.scroll_offset)
104 .take(inner_height)
105 .map(|(i, entry)| {
106 let is_selected = i == self.selected;
107 let is_hovered = self.hover_row == Some(i);
108
109 match entry {
110 DirEntry::ParentDir => {
111 if is_selected && focused {
112 ListItem::new(Line::from(vec![
113 Span::styled(" \u{F07B} ", highlight),
114 Span::styled("..", highlight),
115 ]))
116 } else {
117 let bg = if is_hovered { HOVER_BG } else { Color::Reset };
118 ListItem::new(Line::from(vec![
119 Span::styled(" \u{F07B} ", Style::default().fg(Color::Yellow).bg(bg)),
120 Span::styled("..", Style::default().fg(theme.fg).bg(bg)),
121 ]))
122 }
123 }
124 DirEntry::Directory(name) => {
125 if is_selected && focused {
126 ListItem::new(Line::from(vec![
127 Span::styled(" \u{F07B} ", highlight),
128 Span::styled(format!("{}/", name), highlight),
129 ]))
130 } else {
131 let bg = if is_hovered { HOVER_BG } else { Color::Reset };
132 ListItem::new(Line::from(vec![
133 Span::styled(" \u{F07B} ", Style::default().fg(Color::Green).bg(bg)),
134 Span::styled(format!("{}/", name), Style::default().fg(theme.fg).bg(bg)),
135 ]))
136 }
137 }
138 DirEntry::Track(idx) => {
139 let t = &app.library.tracks[*idx];
140 if is_selected && focused {
141 ListItem::new(Line::from(vec![
142 Span::styled(" \u{F001} ", highlight),
143 Span::styled(&t.title, highlight),
144 ]))
145 } else {
146 let bg = if is_hovered { HOVER_BG } else { Color::Reset };
147 ListItem::new(Line::from(vec![
148 Span::styled(" \u{F001} ", Style::default().fg(Color::Cyan).bg(bg)),
149 Span::styled(&t.title, Style::default().fg(theme.fg).bg(bg)),
150 ]))
151 }
152 }
153 }
154 })
155 .collect();
156
157 let list = List::new(items).block(block);
158 frame.render_widget(list, area);
159
160 if has_scrollbar {
161 let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
162 .begin_symbol(None)
163 .end_symbol(None);
164 let mut scrollbar_state = ScrollbarState::new(count)
165 .position(self.scroll_offset);
166 frame.render_stateful_widget(
167 scrollbar,
168 area.inner(ratatui::layout::Margin { vertical: 1, horizontal: 0 }),
169 &mut scrollbar_state,
170 );
171 }
172 }
173
174 fn handle_key(&mut self, key: KeyEvent, app: &App) -> Option<AppAction> {
175 let count = self.entries.len();
176 if count == 0 {
177 return None;
178 }
179
180 match key.code {
181 KeyCode::Char('j') | KeyCode::Down => {
182 if self.selected < count - 1 {
183 self.selected += 1;
184 }
185 None
186 }
187 KeyCode::Char('k') | KeyCode::Up => {
188 if self.selected > 0 {
189 self.selected -= 1;
190 }
191 None
192 }
193 KeyCode::Enter => {
194 match &self.entries[self.selected] {
195 DirEntry::ParentDir => {
196 if let Some(parent) = self.current_dir.parent() {
197 self.current_dir = parent.to_path_buf();
198 self.selected = 0;
199 self.scroll_offset = 0;
200 self.refresh(app);
201 }
202 }
203 DirEntry::Directory(name) => {
204 self.current_dir = self.current_dir.join(name);
205 self.selected = 0;
206 self.scroll_offset = 0;
207 self.refresh(app);
208 }
209 DirEntry::Track(idx) => {
210 return Some(AppAction::AddToQueue(vec![*idx]));
211 }
212 }
213 None
214 }
215 KeyCode::Backspace => {
216 if let Some(parent) = self.current_dir.parent() {
217 self.current_dir = parent.to_path_buf();
218 self.selected = 0;
219 self.scroll_offset = 0;
220 self.refresh(app);
221 }
222 None
223 }
224 KeyCode::Home | KeyCode::Char('g') => {
225 self.selected = 0;
226 self.scroll_offset = 0;
227 None
228 }
229 KeyCode::End | KeyCode::Char('G') => {
230 self.selected = count.saturating_sub(1);
231 None
232 }
233 _ => None,
234 }
235 }
236
237 fn handle_mouse(&mut self, event: MouseEvent, area: Rect, _app: &App) -> Option<AppAction> {
238 let block = Block::default().borders(Borders::ALL);
239 let inner = block.inner(area);
240 let count = self.entries.len();
241
242 match event.kind {
243 MouseEventKind::Down(MouseButton::Left) => {
244 if event.column >= inner.x
245 && event.column < inner.x + inner.width
246 && event.row >= inner.y
247 && event.row < inner.y + inner.height
248 {
249 let clicked = self.scroll_offset + (event.row - inner.y) as usize;
250 if clicked < count {
251 self.selected = clicked;
252 }
253 }
254 None
255 }
256 MouseEventKind::ScrollDown => self.handle_scroll(false, _app),
257 MouseEventKind::ScrollUp => self.handle_scroll(true, _app),
258 _ => None,
259 }
260 }
261
262 fn handle_scroll(&mut self, up: bool, _app: &App) -> Option<AppAction> {
263 let count = self.entries.len();
264 if count == 0 {
265 return None;
266 }
267 if up {
268 self.scroll_offset = self.scroll_offset.saturating_sub(3);
269 self.selected = self.selected.saturating_sub(3);
270 } else {
271 self.scroll_offset = (self.scroll_offset + 3).min(count.saturating_sub(1));
272 self.selected = (self.selected + 3).min(count.saturating_sub(1));
273 }
274 None
275 }
276 }