/
magnusroot
/
nm
Обзор
Документация
Войти
/
magnusroot
/
nm
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
crates/tui/src/lib.rs
705 строк
22 KB
Magnus Root
New smb
25 июл 2026, 21:00
25 июл 2026, 21:00
c2ac57d
Код
Авторство
О чём код?
mod app; mod ui; use std::io; use std::path::PathBuf; use std::time::{Duration, Instant}; use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; use crossterm::execute; use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}; use ratatui::backend::CrosstermBackend; use ratatui::Terminal; use app::{App, Focus, LibraryView, Tab}; const TICK_RATE: Duration = Duration::from_millis(100); /// Обратное отображение стандартной раскладки ЙЦУКЕН на физические позиции QWERTY. /// Применяется ТОЛЬКО к клавишам-командам (навигация/транспорт), а не к вводу текста — /// так что искать/называть плейлисты и станции кириллицей по-прежнему можно как обычно, /// а горячие клавиши при этом не зависят от того, какая раскладка сейчас активна. fn normalize_layout(c: char) -> char { match c { 'й' => 'q', 'ц' => 'w', 'у' => 'e', 'к' => 'r', 'е' => 't', 'н' => 'y', 'г' => 'u', 'ш' => 'i', 'щ' => 'o', 'з' => 'p', 'х' => '[', 'ъ' => ']', 'ф' => 'a', 'ы' => 's', 'в' => 'd', 'а' => 'f', 'п' => 'g', 'р' => 'h', 'о' => 'j', 'л' => 'k', 'д' => 'l', 'я' => 'z', 'ч' => 'x', 'с' => 'c', 'м' => 'v', 'и' => 'b', 'т' => 'n', 'ь' => 'm', '.' => '/', // физическая клавиша "/" на RU-раскладке без шифта даёт "." 'Й' => 'Q', 'Ц' => 'W', 'У' => 'E', 'К' => 'R', 'Е' => 'T', 'Н' => 'Y', 'Г' => 'U', 'Ш' => 'I', 'Щ' => 'O', 'З' => 'P', 'Ф' => 'A', 'Ы' => 'S', 'В' => 'D', 'А' => 'F', 'П' => 'G', 'Р' => 'H', 'О' => 'J', 'Л' => 'K', 'Д' => 'L', 'Я' => 'Z', 'Ч' => 'X', 'С' => 'C', 'М' => 'V', 'И' => 'B', 'Т' => 'N', 'Ь' => 'M', other => other, } } pub fn run(root: PathBuf) -> anyhow::Result<()> { enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; let mut app = App::new(root)?; let result = event_loop(&mut terminal, &mut app); disable_raw_mode()?; execute!(terminal.backend_mut(), LeaveAlternateScreen)?; terminal.show_cursor()?; result } fn event_loop<B: ratatui::backend::Backend>( terminal: &mut Terminal<B>, app: &mut App, ) -> anyhow::Result<()> { let mut last_tick = Instant::now(); loop { terminal.draw(|f| ui::draw(f, app))?; let timeout = TICK_RATE.saturating_sub(last_tick.elapsed()); if event::poll(timeout)? { if let Event::Key(key) = event::read()? { if key.kind == KeyEventKind::Press { handle_key(app, key.code, key.modifiers); } } } if last_tick.elapsed() >= TICK_RATE { app.drain_player_events(); app.poll_new_releases(); app.poll_station_search(); app.poll_smb_connect(); last_tick = Instant::now(); } if app.should_quit { return Ok(()); } } } fn handle_key(app: &mut App, code: KeyCode, modifiers: KeyModifiers) { if app.fullscreen { handle_fullscreen_key(app, code); return; } match app.focus { Focus::FirstRunPrompt => handle_first_run_prompt_key(app, code), Focus::Search => handle_search_key(app, code), Focus::NewPlaylist => handle_new_playlist_key(app, code), Focus::NewStationName => handle_new_station_name_key(app, code), Focus::NewStationUrl => handle_new_station_url_key(app, code), Focus::StationSearch => handle_station_search_key(app, code), Focus::SmbServer => handle_smb_text_key(app, code, |a| &mut a.smb_server, Focus::SmbShare), Focus::SmbShare => handle_smb_text_key(app, code, |a| &mut a.smb_share, Focus::SmbUsername), Focus::SmbUsername => handle_smb_text_key(app, code, |a| &mut a.smb_username, Focus::SmbPassword), Focus::SmbPassword => handle_smb_password_key(app, code), Focus::LibraryPathInput => handle_library_path_input_key(app, code), Focus::NewReleaseArtistInput => handle_new_release_artist_input_key(app, code), Focus::Eq => handle_eq_key(app, code), Focus::Normal => handle_normal_key(app, code, modifiers), } } fn handle_fullscreen_key(app: &mut App, code: KeyCode) { let code = match code { KeyCode::Char(c) => KeyCode::Char(normalize_layout(c)), other => other, }; match code { KeyCode::Esc | KeyCode::Char('z') => app.toggle_fullscreen(), KeyCode::Char('v') => app.toggle_right_panel_view(), KeyCode::Char('q') => app.should_quit = true, KeyCode::Char(' ') => { app.player.play_pause(); } KeyCode::Char('m') => { app.player.toggle_mute(); } KeyCode::Char('N') => { app.player.next(); } KeyCode::Char('P') => { app.player.previous(); } KeyCode::Right => app.seek_relative(5), KeyCode::Left => app.seek_relative(-5), _ => {} } } fn handle_first_run_prompt_key(app: &mut App, code: KeyCode) { let code = match code { KeyCode::Char(c) => KeyCode::Char(normalize_layout(c)), other => other, }; match code { KeyCode::Char('y') | KeyCode::Char('Y') => app.answer_first_run_prompt(true), KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => app.answer_first_run_prompt(false), _ => {} } } fn handle_search_key(app: &mut App, code: KeyCode) { match code { KeyCode::Esc => { app.search_query.clear(); app.rebuild_library_view(); app.focus = Focus::Normal; } KeyCode::Enter => { app.focus = Focus::Normal; } KeyCode::Backspace => { app.search_query.pop(); app.rebuild_library_view(); } KeyCode::Char(c) => { app.search_query.push(c); app.rebuild_library_view(); } _ => {} } } fn handle_new_playlist_key(app: &mut App, code: KeyCode) { match code { KeyCode::Esc => { app.new_playlist_name.clear(); app.focus = Focus::Normal; } KeyCode::Enter => { let name = app.new_playlist_name.clone(); app.create_playlist(&name); app.new_playlist_name.clear(); app.focus = Focus::Normal; } KeyCode::Backspace => { app.new_playlist_name.pop(); } KeyCode::Char(c) => { app.new_playlist_name.push(c); } _ => {} } } fn handle_new_station_name_key(app: &mut App, code: KeyCode) { match code { KeyCode::Esc => { app.new_station_name.clear(); app.focus = Focus::Normal; } KeyCode::Enter => { if !app.new_station_name.trim().is_empty() { app.focus = Focus::NewStationUrl; } } KeyCode::Backspace => { app.new_station_name.pop(); } KeyCode::Char(c) => { app.new_station_name.push(c); } _ => {} } } fn handle_new_station_url_key(app: &mut App, code: KeyCode) { match code { KeyCode::Esc => { app.new_station_name.clear(); app.new_station_url.clear(); app.focus = Focus::Normal; } KeyCode::Enter => { let name = app.new_station_name.clone(); let url = app.new_station_url.clone(); app.add_station(&name, &url); app.new_station_name.clear(); app.new_station_url.clear(); app.focus = Focus::Normal; } KeyCode::Backspace => { app.new_station_url.pop(); } KeyCode::Char(c) => { app.new_station_url.push(c); } _ => {} } } fn handle_station_search_key(app: &mut App, code: KeyCode) { match code { KeyCode::Esc => { app.station_query.clear(); app.focus = Focus::Normal; } KeyCode::Enter => { let query = app.station_query.clone(); app.search_stations_online(&query); app.focus = Focus::Normal; } KeyCode::Backspace => { app.station_query.pop(); } KeyCode::Char(c) => { app.station_query.push(c); } _ => {} } } fn handle_smb_text_key(app: &mut App, code: KeyCode, field: fn(&mut App) -> &mut String, next: Focus) { match code { KeyCode::Esc => { app.smb_server.clear(); app.smb_share.clear(); app.smb_username.clear(); app.smb_password.clear(); app.focus = Focus::Normal; } KeyCode::Enter => { app.focus = next; } KeyCode::Backspace => { field(app).pop(); } KeyCode::Char(c) => { field(app).push(c); } _ => {} } } fn handle_library_path_input_key(app: &mut App, code: KeyCode) { match code { KeyCode::Esc => { app.library_path_input.clear(); app.focus = Focus::Normal; } KeyCode::Enter => { let path = app.library_path_input.clone(); app.add_library_folder(&path); app.library_path_input.clear(); app.focus = Focus::Normal; } KeyCode::Backspace => { app.library_path_input.pop(); } KeyCode::Char(c) => { app.library_path_input.push(c); } _ => {} } } fn handle_new_release_artist_input_key(app: &mut App, code: KeyCode) { match code { KeyCode::Esc => { app.new_release_artist_input.clear(); app.focus = Focus::Normal; } KeyCode::Enter => { let artist = app.new_release_artist_input.clone(); app.check_manual_artist_new_releases(&artist); app.new_release_artist_input.clear(); app.focus = Focus::Normal; } KeyCode::Backspace => { app.new_release_artist_input.pop(); } KeyCode::Char(c) => { app.new_release_artist_input.push(c); } _ => {} } } fn handle_smb_password_key(app: &mut App, code: KeyCode) { match code { KeyCode::Esc => { app.smb_server.clear(); app.smb_share.clear(); app.smb_username.clear(); app.smb_password.clear(); app.focus = Focus::Normal; } KeyCode::Enter => { app.connect_smb_share(); } KeyCode::Backspace => { app.smb_password.pop(); } KeyCode::Char(c) => { app.smb_password.push(c); } _ => {} } } fn handle_eq_key(app: &mut App, code: KeyCode) { let code = match code { KeyCode::Char(c) => KeyCode::Char(normalize_layout(c)), other => other, }; match code { KeyCode::Esc | KeyCode::Char('e') => app.focus = Focus::Normal, KeyCode::Left => { app.eq_selected = app.eq_selected.saturating_sub(1); } KeyCode::Right => { app.eq_selected = (app.eq_selected + 1).min(player::EQ_BANDS - 1); } KeyCode::Up => app.adjust_eq(1.0), KeyCode::Down => app.adjust_eq(-1.0), KeyCode::Char('0') => { let band = app.eq_selected; let delta = -app.eq_gains[band]; app.adjust_eq(delta); } KeyCode::Char('R') => { app.player.reset_eq(); app.eq_gains = [0.0; player::EQ_BANDS]; } _ => {} } } fn handle_normal_key(app: &mut App, code: KeyCode, modifiers: KeyModifiers) { let code = match code { KeyCode::Char(c) => KeyCode::Char(normalize_layout(c)), other => other, }; // Глобальные транспортные клавиши, доступные на любой вкладке. match code { KeyCode::Char('q') => { app.should_quit = true; return; } KeyCode::Tab => { app.tab = app.tab.next(); return; } KeyCode::Char(' ') => { app.player.play_pause(); return; } KeyCode::Char('n') if modifiers.contains(KeyModifiers::CONTROL) => { app.player.next(); return; } KeyCode::Char('N') => { app.player.next(); return; } KeyCode::Char('P') => { app.player.previous(); return; } KeyCode::Char('s') => { app.shuffle = !app.shuffle; app.player.toggle_shuffle(); return; } KeyCode::Char('r') => { app.player.cycle_repeat(); return; } KeyCode::Char(']') => { app.speed = app.speed.next_up(); app.player.set_speed(app.speed); return; } KeyCode::Char('[') => { app.speed = app.speed.next_down(); app.player.set_speed(app.speed); return; } KeyCode::Char('e') => { app.focus = Focus::Eq; return; } KeyCode::Char('m') => { app.player.toggle_mute(); return; } KeyCode::Char('h') => { app.toggle_history(); return; } KeyCode::Char('v') => { app.toggle_right_panel_view(); return; } KeyCode::Char('z') => { app.toggle_fullscreen(); return; } KeyCode::Char('F') => { app.player.toggle_fade_out(); return; } KeyCode::Char('G') => { app.player.toggle_loudness_norm(); return; } KeyCode::Char('I') => { app.cycle_cover_protocol(); return; } KeyCode::Right => { app.seek_relative(5); return; } KeyCode::Left => { app.seek_relative(-5); return; } _ => {} } match app.tab { Tab::Library => handle_library_key(app, code), Tab::Playlists => handle_playlists_key(app, code), Tab::Queue => handle_queue_key(app, code), Tab::Radio => handle_radio_key(app, code), Tab::Stats => handle_stats_key(app, code), Tab::NewReleases => handle_new_releases_key(app, code), } } fn handle_library_key(app: &mut App, code: KeyCode) { match code { KeyCode::Char('/') => { app.focus = Focus::Search; } KeyCode::Char('a') => { app.add_selected_to_playlist(); } KeyCode::Char('c') => { app.focus = Focus::SmbServer; } KeyCode::Char('L') => { app.focus = Focus::LibraryPathInput; } KeyCode::Char('C') => { app.clear_library(); } KeyCode::Char('t') => { app.toggle_library_mode(); } KeyCode::Down | KeyCode::Char('j') => move_selection(app, 1), KeyCode::Up | KeyCode::Char('k') => move_selection(app, -1), KeyCode::Esc => { if app.expanded_album.is_some() { app.expanded_album = None; app.library_selected = 0; } else if !app.search_query.is_empty() { app.search_query.clear(); app.rebuild_library_view(); } } KeyCode::Enter => match &app.library_view { LibraryView::Albums(rows) => { if app.expanded_album.is_some() { app.play_expanded_track(app.library_selected); } else if !rows.is_empty() { app.expanded_album = Some(app.library_selected); app.library_selected = 0; } } LibraryView::Tracks(_) => { app.play_all_tracks_from_selected(); } LibraryView::Search(_) => { app.play_album_from_selected(); } }, _ => {} } } fn move_selection(app: &mut App, delta: i32) { let len = match &app.library_view { LibraryView::Albums(rows) => { if let Some(idx) = app.expanded_album { rows.get(idx).map(|r| r.tracks.len()).unwrap_or(0) } else { rows.len() } } LibraryView::Tracks(rows) => rows.len(), LibraryView::Search(rows) => rows.len(), }; if len == 0 { return; } let current = app.library_selected as i32; let new = (current + delta).rem_euclid(len as i32); app.library_selected = new as usize; } fn handle_playlists_key(app: &mut App, code: KeyCode) { match code { KeyCode::Char('n') => { app.focus = Focus::NewPlaylist; } KeyCode::Char('d') if app.open_playlist.is_some() => { app.playlist_delete_selected(); } KeyCode::Char('J') if app.open_playlist.is_some() => { app.playlist_move_selected(1); } KeyCode::Char('K') if app.open_playlist.is_some() => { app.playlist_move_selected(-1); } KeyCode::Down | KeyCode::Char('j') => { if app.open_playlist.is_some() { let len = app.open_playlist.as_ref().map(|p| p.tracks.len()).unwrap_or(0); if len > 0 { app.playlist_track_selected = (app.playlist_track_selected + 1) % len; } } else if !app.playlist_names.is_empty() { app.playlists_selected = (app.playlists_selected + 1) % app.playlist_names.len(); } } KeyCode::Up | KeyCode::Char('k') => { if app.open_playlist.is_some() { let len = app.open_playlist.as_ref().map(|p| p.tracks.len()).unwrap_or(0); if len > 0 { app.playlist_track_selected = (app.playlist_track_selected + len - 1) % len; } } else if !app.playlist_names.is_empty() { let len = app.playlist_names.len(); app.playlists_selected = (app.playlists_selected + len - 1) % len; } } KeyCode::Esc => { app.open_playlist = None; } KeyCode::Enter => { if app.open_playlist.is_some() { app.play_open_playlist_from(app.playlist_track_selected); } else if let Some(name) = app.playlist_names.get(app.playlists_selected).cloned() { if let Ok(pl) = app.playlist_store.load(&name) { app.open_playlist = Some(pl); app.playlist_track_selected = 0; } } } _ => {} } } fn handle_queue_key(app: &mut App, code: KeyCode) { match code { KeyCode::Down | KeyCode::Char('j') => { if !app.current_queue.is_empty() { app.queue_selected = (app.queue_selected + 1) % app.current_queue.len(); } } KeyCode::Up | KeyCode::Char('k') => { if !app.current_queue.is_empty() { let len = app.current_queue.len(); app.queue_selected = (app.queue_selected + len - 1) % len; } } KeyCode::Enter => { app.player.set_queue(app.current_queue.clone(), app.queue_selected); } _ => {} } } fn handle_stats_key(app: &mut App, code: KeyCode) { match code { KeyCode::Char('p') => app.cycle_stats_period(), KeyCode::Char('x') => app.request_reset_stats(), _ => app.cancel_reset_arm(), } } fn handle_new_releases_key(app: &mut App, code: KeyCode) { match code { KeyCode::Char('p') => app.cycle_new_releases_period(), KeyCode::Char('f') => app.start_new_releases_fetch(), KeyCode::Char('a') => { app.focus = Focus::NewReleaseArtistInput; } _ => {} } } fn handle_radio_key(app: &mut App, code: KeyCode) { if !app.online_results.is_empty() { handle_online_results_key(app, code); return; } match code { KeyCode::Char('u') => { app.focus = Focus::NewStationName; } KeyCode::Char('o') => { app.focus = Focus::StationSearch; } KeyCode::Char('d') => { app.remove_selected_station(); } KeyCode::Down | KeyCode::Char('j') => { if !app.stations.is_empty() { app.radio_selected = (app.radio_selected + 1) % app.stations.len(); } } KeyCode::Up | KeyCode::Char('k') => { if !app.stations.is_empty() { let len = app.stations.len(); app.radio_selected = (app.radio_selected + len - 1) % len; } } KeyCode::Enter => { app.play_selected_station(); } _ => {} } } fn handle_online_results_key(app: &mut App, code: KeyCode) { match code { KeyCode::Esc => { app.close_online_results(); } KeyCode::Down | KeyCode::Char('j') => { if !app.online_results.is_empty() { app.online_selected = (app.online_selected + 1) % app.online_results.len(); } } KeyCode::Up | KeyCode::Char('k') => { if !app.online_results.is_empty() { let len = app.online_results.len(); app.online_selected = (app.online_selected + len - 1) % len; } } KeyCode::Enter => { app.play_online_result(); } KeyCode::Char('a') => { app.add_online_result_to_custom(); } _ => {} } }