rmptui32a3c60
add rename playlists feature
+181 −65
4 files changed
- M README.md +2 −2
- M src/app.rs +101 −25
- M src/handler.rs +43 −8
- M src/ui.rs +35 −30
| @@ -39,5 +39,5 @@ A MPD client in Rust | |||
| 39 | 39 | - [x] search for songs | |
| 40 | 40 | - [x] Human readable time format | |
| 41 | 41 | - [x] metadata based tree view | |
| 42 | − | - [ ] view playlist | |
| 43 | − | - [ ] change playlist name | |
| 42 | + | - [x] view playlist | |
| 43 | + | - [x] change playlist name | |
| @@ -21,9 +21,11 @@ pub struct App { | |||
| 21 | 21 | pub selected_tab: SelectedTab, // Used to switch between tabs | |
| 22 | 22 | ||
| 23 | 23 | // Search | |
| 24 | − | pub inputmode: InputMode, // Defines input mode, Normal or Search | |
| 25 | − | pub search_input: String, // Stores the userinput to be searched | |
| 26 | − | pub cursor_position: usize, // Stores the cursor position | |
| 24 | + | pub inputmode: InputMode, // Defines input mode, Normal or Search | |
| 25 | + | pub search_input: String, // Stores the userinput to be searched | |
| 26 | + | pub search_cursor_pos: usize, // Stores the cursor position for searching | |
| 27 | + | pub pl_newname_input: String, // Stores the new name of the playlist | |
| 28 | + | pub pl_cursor_pos: usize, // Stores the cursor position for renaming playlist | |
| 27 | 29 | ||
| 28 | 30 | // playlist variables | |
| 29 | 31 | // used to show playlist popup | |
| @@ -59,7 +61,9 @@ impl App { | |||
| 59 | 61 | browser, | |
| 60 | 62 | inputmode: InputMode::Normal, | |
| 61 | 63 | search_input: String::new(), | |
| 62 | − | cursor_position: 0, | |
| 64 | + | pl_newname_input: String::new(), | |
| 65 | + | search_cursor_pos: 0, | |
| 66 | + | pl_cursor_pos: 0, | |
| 63 | 67 | playlist_popup: false, | |
| 64 | 68 | append_list, | |
| 65 | 69 | }) | |
| @@ -97,7 +101,7 @@ impl App { | |||
| 97 | 101 | Self::get_queue(&mut self.conn, &mut self.queue_list.list); | |
| 98 | 102 | } | |
| 99 | 103 | ||
| 100 | − | fn get_playlist(conn: &mut Client) -> AppResult<Vec<String>> { | |
| 104 | + | pub fn get_playlist(conn: &mut Client) -> AppResult<Vec<String>> { | |
| 101 | 105 | let list: Vec<String> = conn.playlists()?.iter().map(|p| p.clone().name).collect(); | |
| 102 | 106 | Ok(list) | |
| 103 | 107 | } | |
| @@ -220,49 +224,111 @@ impl App { | |||
| 220 | 224 | ||
| 221 | 225 | // Cursor movements | |
| 222 | 226 | pub fn move_cursor_left(&mut self) { | |
| 223 | − | let cursor_moved_left = self.cursor_position.saturating_sub(1); | |
| 224 | − | self.cursor_position = self.clamp_cursor(cursor_moved_left); | |
| 227 | + | match self.inputmode { | |
| 228 | + | InputMode::PlaylistRename => { | |
| 229 | + | let cursor_moved_left = self.pl_cursor_pos.saturating_sub(1); | |
| 230 | + | self.pl_cursor_pos = self.clamp_cursor(cursor_moved_left); | |
| 231 | + | } | |
| 232 | + | InputMode::Editing => { | |
| 233 | + | let cursor_moved_left = self.search_cursor_pos.saturating_sub(1); | |
| 234 | + | self.search_cursor_pos = self.clamp_cursor(cursor_moved_left); | |
| 235 | + | } | |
| 236 | + | _ => {} | |
| 237 | + | } | |
| 225 | 238 | } | |
| 226 | 239 | ||
| 227 | 240 | pub fn move_cursor_right(&mut self) { | |
| 228 | − | let cursor_moved_right = self.cursor_position.saturating_add(1); | |
| 229 | − | self.cursor_position = self.clamp_cursor(cursor_moved_right); | |
| 241 | + | match self.inputmode { | |
| 242 | + | InputMode::PlaylistRename => { | |
| 243 | + | let cursor_moved_right = self.pl_cursor_pos.saturating_add(1); | |
| 244 | + | self.pl_cursor_pos = self.clamp_cursor(cursor_moved_right); | |
| 245 | + | } | |
| 246 | + | InputMode::Editing => { | |
| 247 | + | let cursor_moved_right = self.search_cursor_pos.saturating_add(1); | |
| 248 | + | self.search_cursor_pos = self.clamp_cursor(cursor_moved_right); | |
| 249 | + | } | |
| 250 | + | _ => {} | |
| 251 | + | } | |
| 230 | 252 | } | |
| 231 | 253 | ||
| 232 | 254 | pub fn enter_char(&mut self, new_char: char) { | |
| 233 | − | self.search_input.insert(self.cursor_position, new_char); | |
| 234 | − | ||
| 235 | − | self.move_cursor_right(); | |
| 255 | + | match self.inputmode { | |
| 256 | + | InputMode::PlaylistRename => { | |
| 257 | + | self.pl_newname_input.insert(self.pl_cursor_pos, new_char); | |
| 258 | + | self.move_cursor_right(); | |
| 259 | + | } | |
| 260 | + | InputMode::Editing => { | |
| 261 | + | self.search_input.insert(self.search_cursor_pos, new_char); | |
| 262 | + | self.move_cursor_right(); | |
| 263 | + | } | |
| 264 | + | _ => {} | |
| 265 | + | } | |
| 236 | 266 | } | |
| 237 | 267 | ||
| 238 | 268 | pub fn delete_char(&mut self) { | |
| 239 | − | let is_not_cursor_leftmost = self.cursor_position != 0; | |
| 269 | + | let is_not_cursor_leftmost = match self.inputmode { | |
| 270 | + | InputMode::PlaylistRename => self.pl_cursor_pos != 0, | |
| 271 | + | InputMode::Editing => self.search_cursor_pos != 0, | |
| 272 | + | _ => false, | |
| 273 | + | }; | |
| 274 | + | ||
| 240 | 275 | if is_not_cursor_leftmost { | |
| 241 | 276 | // Method "remove" is not used on the saved text for deleting the selected char. | |
| 242 | 277 | // Reason: Using remove on String works on bytes instead of the chars. | |
| 243 | 278 | // Using remove would require special care because of char boundaries. | |
| 244 | 279 | ||
| 245 | − | let current_index = self.cursor_position; | |
| 246 | − | let from_left_to_current_index = current_index - 1; | |
| 280 | + | let current_index = match self.inputmode { | |
| 281 | + | InputMode::Editing => self.search_cursor_pos, | |
| 282 | + | InputMode::PlaylistRename => self.pl_cursor_pos, | |
| 283 | + | _ => 0, | |
| 284 | + | }; | |
| 247 | 285 | ||
| 248 | − | // Getting all characters before the selected character. | |
| 249 | − | let before_char_to_delete = self.search_input.chars().take(from_left_to_current_index); | |
| 250 | − | // Getting all characters after selected character. | |
| 251 | − | let after_char_to_delete = self.search_input.chars().skip(current_index); | |
| 286 | + | let from_left_to_current_index = current_index - 1; | |
| 252 | 287 | ||
| 253 | − | // Put all characters together except the selected one. | |
| 254 | − | // By leaving the selected one out, it is forgotten and therefore deleted. | |
| 255 | − | self.search_input = before_char_to_delete.chain(after_char_to_delete).collect(); | |
| 256 | − | self.move_cursor_left(); | |
| 288 | + | if self.inputmode == InputMode::PlaylistRename { | |
| 289 | + | // Getting all characters before the selected character. | |
| 290 | + | let before_char_to_delete = self | |
| 291 | + | .pl_newname_input | |
| 292 | + | .chars() | |
| 293 | + | .take(from_left_to_current_index); | |
| 294 | + | // Getting all characters after selected character. | |
| 295 | + | let after_char_to_delete = self.pl_newname_input.chars().skip(current_index); | |
| 296 | + | // Put all characters together except the selected one. | |
| 297 | + | // By leaving the selected one out, it is forgotten and therefore deleted. | |
| 298 | + | self.pl_newname_input = before_char_to_delete.chain(after_char_to_delete).collect(); | |
| 299 | + | self.move_cursor_left(); | |
| 300 | + | } else if self.inputmode == InputMode::Editing { | |
| 301 | + | // Getting all characters before the selected character. | |
| 302 | + | let before_char_to_delete = | |
| 303 | + | self.search_input.chars().take(from_left_to_current_index); | |
| 304 | + | // Getting all characters after selected character. | |
| 305 | + | let after_char_to_delete = self.search_input.chars().skip(current_index); | |
| 306 | + | // Put all characters together except the selected one. | |
| 307 | + | // By leaving the selected one out, it is forgotten and therefore deleted. | |
| 308 | + | self.search_input = before_char_to_delete.chain(after_char_to_delete).collect(); | |
| 309 | + | self.move_cursor_left(); | |
| 310 | + | } | |
| 257 | 311 | } | |
| 258 | 312 | } | |
| 259 | 313 | ||
| 260 | 314 | pub fn clamp_cursor(&self, new_cursor_pos: usize) -> usize { | |
| 261 | − | new_cursor_pos.clamp(0, self.search_input.len()) | |
| 315 | + | match self.inputmode { | |
| 316 | + | InputMode::PlaylistRename => new_cursor_pos.clamp(0, self.pl_newname_input.len()), | |
| 317 | + | InputMode::Editing => new_cursor_pos.clamp(0, self.search_input.len()), | |
| 318 | + | _ => 0, | |
| 319 | + | } | |
| 262 | 320 | } | |
| 263 | 321 | ||
| 264 | 322 | pub fn reset_cursor(&mut self) { | |
| 265 | − | self.cursor_position = 0; | |
| 323 | + | match self.inputmode { | |
| 324 | + | InputMode::Editing => { | |
| 325 | + | self.search_cursor_pos = 0; | |
| 326 | + | } | |
| 327 | + | InputMode::PlaylistRename => { | |
| 328 | + | self.pl_cursor_pos = 0; | |
| 329 | + | } | |
| 330 | + | _ => {} | |
| 331 | + | } | |
| 266 | 332 | } | |
| 267 | 333 | ||
| 268 | 334 | /// Given time in seconds, convert it to hh:mm:ss | |
| @@ -277,4 +343,14 @@ impl App { | |||
| 277 | 343 | format!("{:02}:{:02}:{:02}", h, m, s) | |
| 278 | 344 | } | |
| 279 | 345 | } | |
| 346 | + | ||
| 347 | + | pub fn change_playlist_name(&mut self) -> AppResult<()> { | |
| 348 | + | match self.selected_tab { | |
| 349 | + | SelectedTab::Playlists => { | |
| 350 | + | self.inputmode = InputMode::PlaylistRename; | |
| 351 | + | } | |
| 352 | + | _ => {} | |
| 353 | + | } | |
| 354 | + | Ok(()) | |
| 355 | + | } | |
| 280 | 356 | } | |
| @@ -92,8 +92,8 @@ pub fn handle_key_events(key_event: KeyEvent, app: &mut App) -> AppResult<()> { | |||
| 92 | 92 | } | |
| 93 | 93 | ||
| 94 | 94 | app.search_input.clear(); | |
| 95 | − | app.inputmode = InputMode::Normal; | |
| 96 | 95 | app.reset_cursor(); | |
| 96 | + | app.inputmode = InputMode::Normal; | |
| 97 | 97 | } | |
| 98 | 98 | ||
| 99 | 99 | KeyCode::Backspace => { | |
| @@ -110,10 +110,41 @@ pub fn handle_key_events(key_event: KeyEvent, app: &mut App) -> AppResult<()> { | |||
| 110 | 110 | ||
| 111 | 111 | _ => {} | |
| 112 | 112 | } | |
| 113 | + | } else if app.inputmode == InputMode::PlaylistRename { | |
| 114 | + | match key_event.code { | |
| 115 | + | KeyCode::Esc => { | |
| 116 | + | app.pl_newname_input.clear(); | |
| 117 | + | app.reset_cursor(); | |
| 118 | + | app.inputmode = InputMode::Normal; | |
| 119 | + | } | |
| 120 | + | KeyCode::Char(to_insert) => { | |
| 121 | + | app.enter_char(to_insert); | |
| 122 | + | } | |
| 123 | + | KeyCode::Enter => { | |
| 124 | + | app.conn.conn.pl_rename(app.pl_list.list.get(app.pl_list.index).unwrap(), &app.pl_newname_input)?; | |
| 125 | + | app.pl_list.list = App::get_playlist(&mut app.conn.conn)?; | |
| 126 | + | app.pl_newname_input.clear(); | |
| 127 | + | app.reset_cursor(); | |
| 128 | + | app.inputmode = InputMode::Normal; | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | KeyCode::Backspace => { | |
| 132 | + | app.delete_char(); | |
| 133 | + | } | |
| 113 | 134 | ||
| 114 | − | // Playlist popup keybinds | |
| 115 | − | // | |
| 116 | − | // Keybind for when the "append to playlist" popup is visible | |
| 135 | + | KeyCode::Left => { | |
| 136 | + | app.move_cursor_left(); | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | KeyCode::Right => { | |
| 140 | + | app.move_cursor_right(); | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | _ => {} | |
| 144 | + | } | |
| 145 | + | // Playlist popup keybinds | |
| 146 | + | // | |
| 147 | + | // Keybind for when the "append to playlist" popup is visible | |
| 117 | 148 | } else if app.playlist_popup { | |
| 118 | 149 | match key_event.code { | |
| 119 | 150 | KeyCode::Char('q') | KeyCode::Esc => { | |
| @@ -358,7 +389,11 @@ pub fn handle_key_events(key_event: KeyEvent, app: &mut App) -> AppResult<()> { | |||
| 358 | 389 | ||
| 359 | 390 | // Search for songs | |
| 360 | 391 | KeyCode::Char('/') => { | |
| 361 | − | app.inputmode = InputMode::toggle_editing_states(&app.inputmode); | |
| 392 | + | if app.inputmode == InputMode::Normal { | |
| 393 | + | app.inputmode = InputMode::Editing; | |
| 394 | + | } else { | |
| 395 | + | app.inputmode = InputMode::Normal; | |
| 396 | + | } | |
| 362 | 397 | } | |
| 363 | 398 | ||
| 364 | 399 | // Remove from Current Playlsit | |
| @@ -366,9 +401,6 @@ pub fn handle_key_events(key_event: KeyEvent, app: &mut App) -> AppResult<()> { | |||
| 366 | 401 | app.handle_add_or_remove_from_current_playlist()?; | |
| 367 | 402 | } | |
| 368 | 403 | ||
| 369 | − | // Change playlist name | |
| 370 | − | KeyCode::Char('e') => if app.selected_tab == SelectedTab::Playlists {}, | |
| 371 | − | ||
| 372 | 404 | // go to top of list | |
| 373 | 405 | KeyCode::Char('g') => match app.selected_tab { | |
| 374 | 406 | SelectedTab::Queue => app.queue_list.index = 0, | |
| @@ -384,6 +416,9 @@ pub fn handle_key_events(key_event: KeyEvent, app: &mut App) -> AppResult<()> { | |||
| 384 | 416 | } | |
| 385 | 417 | SelectedTab::Playlists => app.pl_list.index = app.pl_list.list.len() - 1, | |
| 386 | 418 | }, | |
| 419 | + | ||
| 420 | + | // Change playlist name | |
| 421 | + | KeyCode::Char('e') => app.change_playlist_name()?, | |
| 387 | 422 | _ => {} | |
| 388 | 423 | } | |
| 389 | 424 | } | |
| @@ -11,15 +11,7 @@ use ratatui::{ | |||
| 11 | 11 | pub enum InputMode { | |
| 12 | 12 | Editing, | |
| 13 | 13 | Normal, | |
| 14 | − | } | |
| 15 | − | ||
| 16 | − | impl InputMode { | |
| 17 | − | pub fn toggle_editing_states(state: &InputMode) -> InputMode { | |
| 18 | − | match state { | |
| 19 | − | InputMode::Editing => return InputMode::Normal, | |
| 20 | − | InputMode::Normal => return InputMode::Editing, | |
| 21 | − | }; | |
| 22 | − | } | |
| 14 | + | PlaylistRename, | |
| 23 | 15 | } | |
| 24 | 16 | ||
| 25 | 17 | /// Renders the user interface widgets | |
| @@ -32,7 +24,6 @@ pub fn render(app: &mut App, frame: &mut Frame) { | |||
| 32 | 24 | ||
| 33 | 25 | match app.selected_tab { | |
| 34 | 26 | SelectedTab::Queue => draw_queue(frame, app, layout[0]), | |
| 35 | − | // SelectedTab::Playlists => draw_playlists(frame, app, layout[0]), | |
| 36 | 27 | SelectedTab::Playlists => draw_playlist_viewer(frame, app, layout[0]), | |
| 37 | 28 | SelectedTab::DirectoryBrowser => draw_directory_browser(frame, app, layout[0]), | |
| 38 | 29 | } | |
| @@ -44,6 +35,9 @@ pub fn render(app: &mut App, frame: &mut Frame) { | |||
| 44 | 35 | InputMode::Editing => { | |
| 45 | 36 | draw_search_bar(frame, app, layout[1]); | |
| 46 | 37 | } | |
| 38 | + | InputMode::PlaylistRename => { | |
| 39 | + | draw_rename_playlist(frame, app, layout[1]); | |
| 40 | + | } | |
| 47 | 41 | } | |
| 48 | 42 | ||
| 49 | 43 | if app.playlist_popup { | |
| @@ -254,27 +248,18 @@ fn draw_queue(frame: &mut Frame, app: &mut App, size: Rect) { | |||
| 254 | 248 | frame.render_stateful_widget(table, size, &mut state); | |
| 255 | 249 | } | |
| 256 | 250 | ||
| 257 | − | ||
| 258 | 251 | // Draw search bar | |
| 259 | 252 | fn draw_search_bar(frame: &mut Frame, app: &mut App, size: Rect) { | |
| 260 | − | match app.inputmode { | |
| 261 | − | InputMode::Normal => | |
| 262 | − | // Hide the cursor. `Frame` does this by default, so we don't need to do anything here | |
| 263 | − | {} | |
| 264 | − | ||
| 265 | − | InputMode::Editing => { | |
| 266 | − | // Make the cursor visible and ask ratatui to put it at the specified coordinates after | |
| 267 | − | // rendering | |
| 268 | − | #[allow(clippy::cast_possible_truncation)] | |
| 269 | − | frame.set_cursor( | |
| 270 | − | // Draw the cursor at the current position in the input field. | |
| 271 | − | // This position is can be controlled via the left and right arrow key | |
| 272 | − | size.x + app.cursor_position as u16 + 2, | |
| 273 | − | // Move one line down, from the border to the input line | |
| 274 | − | size.y + 1, | |
| 275 | − | ); | |
| 276 | − | } | |
| 277 | − | } | |
| 253 | + | // Make the cursor visible and ask ratatui to put it at the specified coordinates after | |
| 254 | + | // rendering | |
| 255 | + | #[allow(clippy::cast_possible_truncation)] | |
| 256 | + | frame.set_cursor( | |
| 257 | + | // Draw the cursor at the current position in the input field. | |
| 258 | + | // This position is can be controlled via the left and right arrow key | |
| 259 | + | size.x + app.search_cursor_pos as u16 + 2, | |
| 260 | + | // Move one line down, from the border to the input line | |
| 261 | + | size.y + 1, | |
| 262 | + | ); | |
| 278 | 263 | ||
| 279 | 264 | let input = Paragraph::new("/".to_string() + &app.search_input) | |
| 280 | 265 | .style(Style::default()) | |
| @@ -399,7 +384,7 @@ fn draw_playlist_viewer(frame: &mut Frame, app: &mut App, area: Rect) { | |||
| 399 | 384 | let table = Table::new( | |
| 400 | 385 | rows, | |
| 401 | 386 | vec![ | |
| 402 | − | Constraint::Percentage(40), | |
| 387 | + | Constraint::Min(40), | |
| 403 | 388 | Constraint::Percentage(40), | |
| 404 | 389 | Constraint::Percentage(20), | |
| 405 | 390 | ], | |
| @@ -439,6 +424,26 @@ fn draw_add_to_playlist(frame: &mut Frame, app: &mut App, area: Rect) { | |||
| 439 | 424 | frame.render_stateful_widget(list, area, &mut state); | |
| 440 | 425 | } | |
| 441 | 426 | ||
| 427 | + | fn draw_rename_playlist(frame: &mut Frame, app: &mut App, area: Rect) { | |
| 428 | + | #[allow(clippy::cast_possible_truncation)] | |
| 429 | + | frame.set_cursor( | |
| 430 | + | // Draw the cursor at the current position in the input field. | |
| 431 | + | // This position is can be controlled via the left and right arrow key | |
| 432 | + | area.x + app.pl_cursor_pos as u16 + 2, | |
| 433 | + | // Move one line down, from the border to the input line | |
| 434 | + | area.y + 1, | |
| 435 | + | ); | |
| 436 | + | ||
| 437 | + | let input = Paragraph::new("/".to_string() + &app.pl_newname_input) | |
| 438 | + | .style(Style::default()) | |
| 439 | + | .block( | |
| 440 | + | Block::default() | |
| 441 | + | .borders(Borders::ALL) | |
| 442 | + | .title("Enter New Name: ".bold().green()), | |
| 443 | + | ); | |
| 444 | + | frame.render_widget(input, area); | |
| 445 | + | } | |
| 446 | + | ||
| 442 | 447 | fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { | |
| 443 | 448 | let popup_layout = Layout::vertical([ | |
| 444 | 449 | Constraint::Percentage((100 - percent_y) / 2), | |