//! The window driven the way a terminal drives it: keys and mouse events in, //! a rendered screen out, over a stand-in session. //! //! This file is the harness the topic modules below share — the session //! double, the window constructors, and the press/screen helpers. mod browse; mod layout; mod navigation; mod rooms; mod searches; mod session; mod settings; mod transfers; use super::*; use crate::daemon::proto::ChatMessageDto; use crate::models::{FocusedPane, MessageDirection, RoomsView, SearchStatus}; use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; /// A stand-in for a daemon: it already holds a conversation, a transfer /// queue, and a search cache, none of which this window created. #[derive(Default)] struct TalkativeSession { history: Vec, downloads: Vec, searches: std::sync::Mutex>, /// What `try_get_search_results` hands back to the window. results: std::sync::Mutex>, /// Every query a window put on the wire, in order. searched: std::sync::Mutex>, /// How many times a window asked for the search list. all_searches_calls: std::sync::atomic::AtomicUsize, /// How many times a window pulled a full result set across. Against a /// daemon each pull is the whole set over the socket, so the tests /// count them. result_fetches: std::sync::atomic::AtomicUsize, /// Whether this session is a daemon's. Local sessions do not sync. shared: bool, /// What `set_download_directory` was last asked for, if anything. download_dir_set: std::sync::Mutex>, /// What `set_shared_directories` was last asked for, if anything. shares_set: std::sync::Mutex>>, /// What `change_password` was last asked for, if anything. password_set: std::sync::Mutex>, /// The share index this session serves to peers. listing: std::sync::Mutex>, download_cancelled: std::sync::Mutex>, upload_cancelled: std::sync::Mutex>, download_removed: std::sync::Mutex>, } fn queued(username: &str, filename: &str) -> soulseek_rs::types::Download { at_status(username, filename, soulseek_rs::DownloadStatus::Queued) } fn completed(username: &str, filename: &str) -> soulseek_rs::types::Download { at_status(username, filename, soulseek_rs::DownloadStatus::Completed) } fn uploading(username: &str, filename: &str) -> soulseek_rs::types::UploadInfo { soulseek_rs::types::UploadInfo { username: username.to_string(), filename: filename.to_string(), size: 10, bytes_sent: 1, speed_bytes_per_sec: 1.0, status: soulseek_rs::types::UploadStatus::InProgress, } } fn at_status( username: &str, filename: &str, status: soulseek_rs::DownloadStatus, ) -> soulseek_rs::types::Download { let (sender, _) = std::sync::mpsc::channel(); soulseek_rs::types::Download { username: username.to_string(), filename: filename.to_string(), token: 1, size: 4096, download_directory: "/tmp".to_string(), status, sender, queue_position: None, metadata: soulseek_rs::types::DownloadMetadata::default(), } } fn key(ch: char) -> KeyEvent { KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE) } impl SessionApi for TalkativeSession { fn message_history(&self) -> Vec { self.history.clone() } fn daemon_endpoint(&self) -> Option { self.shared.then(|| "test-daemon".to_string()) } fn username(&self) -> String { "tester".to_string() } fn listen_port(&self) -> Option { None } fn session_loss(&self) -> Option { None } fn search_with_cancel( &self, query: &str, _timeout: Duration, _cancel: Option>, ) -> soulseek_rs::Result> { self.searched .lock() .expect("not poisoned") .push(query.to_string()); Ok(Vec::new()) } fn get_search_results(&self, _key: &str) -> Vec { Vec::new() } fn all_searches(&self) -> Vec { self.all_searches_calls .fetch_add(1, std::sync::atomic::Ordering::Relaxed); self.searches.lock().expect("not poisoned").clone() } fn forget_search(&self, query: &str) -> bool { let mut searches = self.searches.lock().expect("not poisoned"); let before = searches.len(); searches.retain(|search| search.query != query); searches.len() != before } fn get_search_results_count(&self, _key: &str) -> usize { 0 } fn try_get_search_results( &self, _key: &str, ) -> Option> { self.result_fetches .fetch_add(1, std::sync::atomic::Ordering::Relaxed); Some(self.results.lock().expect("not poisoned").clone()) } fn start_wishlist_search(&self, _query: &str) -> soulseek_rs::Result<()> { Ok(()) } fn wishlist_interval(&self) -> Duration { Duration::from_mins(12) } fn download( &self, _filename: String, _username: String, _size: u64, _directory: String, ) -> soulseek_rs::Result<( soulseek_rs::types::Download, std::sync::mpsc::Receiver, )> { Err(soulseek_rs::SoulseekRs::NotConnected) } fn download_with_metadata( &self, _filename: String, _username: String, _size: u64, _directory: String, _metadata: soulseek_rs::types::DownloadMetadata, ) -> soulseek_rs::Result<( soulseek_rs::types::Download, std::sync::mpsc::Receiver, )> { Err(soulseek_rs::SoulseekRs::NotConnected) } fn get_all_downloads(&self) -> Vec { self.downloads.clone() } fn pause_download(&self, _u: &str, _f: &str) -> bool { false } fn resume_download(&self, _u: &str, _f: &str) -> bool { false } fn remove_queued_download(&self, _u: &str, _f: &str) -> bool { false } fn remove_download(&self, u: &str, f: &str) -> bool { self.download_removed .lock() .expect("not poisoned") .push((u.to_string(), f.to_string())); true } fn uploads(&self) -> Vec { Vec::new() } fn take_upload_events(&self) -> Vec { Vec::new() } fn cancel_upload(&self, u: &str, f: &str) -> bool { self.upload_cancelled .lock() .expect("not poisoned") .push((u.to_string(), f.to_string())); true } fn cancel_download(&self, u: &str, f: &str) -> bool { self.download_cancelled .lock() .expect("not poisoned") .push((u.to_string(), f.to_string())); true } fn set_upload_slots(&self, _slots: usize) {} fn check_privileges(&self) -> soulseek_rs::Result<()> { Ok(()) } fn own_privilege_seconds(&self) -> Option { None } fn request_room_list(&self) -> soulseek_rs::Result<()> { Ok(()) } fn join_room(&self, _room: &str) -> soulseek_rs::Result<()> { Ok(()) } fn leave_room(&self, _room: &str) -> soulseek_rs::Result<()> { Ok(()) } fn say_in_room( &self, _room: &str, _message: &str, ) -> soulseek_rs::Result<()> { Ok(()) } fn room_members(&self, _room: &str) -> Vec { Vec::new() } fn room_member_stats( &self, _room: &str, ) -> Vec { Vec::new() } fn take_room_events(&self) -> Vec { Vec::new() } fn send_private_message( &self, _u: &str, _m: &str, ) -> soulseek_rs::Result<()> { Ok(()) } fn take_private_messages(&self) -> Vec { Vec::new() } fn browse_user(&self, _username: &str) -> soulseek_rs::Result<()> { Ok(()) } fn take_browse_result( &self, _username: &str, ) -> Option> { None } fn request_user_info(&self, _username: &str) -> soulseek_rs::Result<()> { Ok(()) } fn watch_user(&self, _username: &str) -> soulseek_rs::Result<()> { Ok(()) } fn unwatch_user(&self, _username: &str) -> soulseek_rs::Result<()> { Ok(()) } fn watched_users(&self) -> Vec { Vec::new() } fn user_info(&self, _username: &str) -> Option { None } fn shared_counts(&self) -> (u32, u32) { (3, 12) } fn change_password(&self, password: &str) -> soulseek_rs::Result<()> { *self.password_set.lock().expect("not poisoned") = Some(password.to_string()); Ok(()) } fn shared_listing(&self) -> Vec { self.listing.lock().expect("not poisoned").clone() } fn shared_directories(&self) -> Vec { Vec::new() } fn set_shared_directories( &self, directories: Vec, ) -> soulseek_rs::Result<()> { *self.shares_set.lock().expect("not poisoned") = Some(directories); Ok(()) } fn set_download_directory( &self, directory: String, ) -> soulseek_rs::Result<()> { *self.download_dir_set.lock().expect("not poisoned") = Some(directory); Ok(()) } } fn tui(history: Vec) -> MainTui { with_session(TalkativeSession { history, shared: true, ..TalkativeSession::default() }) } fn shared(searches: Vec) -> TalkativeSession { TalkativeSession { searches: std::sync::Mutex::new(searches), shared: true, ..TalkativeSession::default() } } fn search_of(query: &str, files: usize, age: u64) -> crate::api::SessionSearch { crate::api::SessionSearch { query: query.to_string(), files, started_secs_ago: Some(age), } } fn with_session(session: TalkativeSession) -> MainTui { attach(Arc::new(session)) } fn attach(session: Arc) -> MainTui { MainTui::new( session, "/tmp".to_string(), false, Duration::from_secs(1), None, None, ) } fn files_from_bob(names: &[&str]) -> soulseek_rs::SearchResult { soulseek_rs::SearchResult { token: 0, files: names .iter() .map(|name| soulseek_rs::types::File { username: "bob".to_string(), name: (*name).to_string(), size: 42, attribs: std::collections::HashMap::new(), }) .collect(), slots: 1, speed: 0, username: "bob".to_string(), } } /// An attached window that selected a two-file search and has already /// ticked past its one necessary fetch. fn window_that_pulled_two_files() -> (Arc, MainTui) { let session = Arc::new(TalkativeSession { results: std::sync::Mutex::new(vec![files_from_bob(&[ "@@x\\a.mp3", "@@x\\b.mp3", ])]), ..shared(vec![search_of("aphex twin", 2, 9_999)]) }); let mut tui = attach(session.clone()); tui.state.selected_search_index = Some(0); tui.update_search_results(); tui.update_search_results(); (session, tui) } fn results(n: usize) -> Vec { (0..n) .map(|i| crate::models::FileDisplayData { filename: format!("@@x\\Music\\{i:02}.mp3"), username: "bob".to_string(), ..Default::default() }) .collect() } fn results_tui(n: usize) -> MainTui { let mut tui = with_session(TalkativeSession::default()); tui.state.results_items = results(n); tui.state.focused_pane = FocusedPane::Results; // 88 cells wide gives the name column 11 cells and the folder 7. tui.state.results_pane_area = Some(ratatui::layout::Rect::new(0, 0, 88, 13)); tui } fn press(tui: &mut MainTui, code: KeyCode) { tui.handle_key_event(KeyEvent::new(code, KeyModifiers::NONE)); } fn ctrl(tui: &mut MainTui, ch: char) { tui.handle_key_event(KeyEvent::new( KeyCode::Char(ch), KeyModifiers::CONTROL, )); } fn selected(tui: &MainTui) -> Option { tui.state.results_table_state.selected() } fn screen_sized(tui: &mut MainTui, width: u16, height: u16) -> String { let backend = ratatui::backend::TestBackend::new(width, height); let mut terminal = ratatui::Terminal::new(backend).expect("backend"); terminal.draw(|frame| tui.render(frame)).expect("draw"); terminal.backend().to_string() } fn screen_of(tui: &mut MainTui) -> String { screen_sized(tui, 160, 40) } fn mouse( tui: &mut MainTui, kind: ratatui::crossterm::event::MouseEventKind, column: u16, row: u16, ) { tui.handle_mouse_event(ratatui::crossterm::event::MouseEvent { kind, column, row, modifiers: KeyModifiers::NONE, }); } fn mouse_drag_row(tui: &mut MainTui, from: (u16, u16), to: (u16, u16)) { use ratatui::crossterm::event::{MouseButton, MouseEventKind}; mouse(tui, MouseEventKind::Down(MouseButton::Left), from.0, from.1); mouse(tui, MouseEventKind::Drag(MouseButton::Left), to.0, to.1); mouse(tui, MouseEventKind::Up(MouseButton::Left), to.0, to.1); } /// The screen row carrying a pane's legend, `[2] Results` say, without /// the quotes the test backend wraps each row in. fn row_with<'a>(screen: &'a str, needle: &str) -> &'a str { let line = screen .lines() .find(|line| line.contains(needle)) .unwrap_or_else(|| panic!("no row with {needle:?} in\n{screen}")); let start = line.find('"').map_or(0, |i| i + 1); let end = line.rfind('"').unwrap_or(line.len()); &line[start..end] } /// A window with a search, its results, and a transfer, so every pane /// has something to show. fn furnished_tui() -> MainTui { let mut tui = results_tui(3); tui.state.searches.push(crate::models::SearchEntry { query: "aphex twin".to_string(), status: SearchStatus::Completed, results: results(3), known_files: 3, owned: true, start_time: std::time::Instant::now(), cancel_flag: Arc::new(std::sync::atomic::AtomicBool::new(false)), }); tui.state.selected_search_index = Some(0); tui.state.downloads.push(crate::models::DownloadEntry { download: queued("bob", "@@x\\Music\\00.mp3"), receiver: None, }); tui } fn browsed(tui: &mut MainTui, files: usize) { tui.state.browse.open("bob"); let listing = vec![soulseek_rs::SharedDirectory { name: "Music".to_string(), files: (0..files) .map(|i| soulseek_rs::SharedFileEntry { name: format!("{i:02}.mp3"), size: 1, attributes: Vec::new(), }) .collect(), }]; tui.state .browse .active_tab_mut() .expect("tab") .load(&listing); tui.state.show_browse = true; let _ = screen_of(tui); } fn in_a_room(tui: &mut MainTui, messages: usize) { tui.state.rooms.apply_event( soulseek_rs::RoomEvent::Joined { room: "jazz".to_string(), users: (0..30).map(|i| format!("user{i:02}")).collect(), }, None, ); tui.state.rooms.focus_or_open("jazz"); for i in 0..messages { tui.state.rooms.apply_event( soulseek_rs::RoomEvent::Message { room: "jazz".to_string(), username: "alice".to_string(), message: format!("line {i:03}"), }, Some("jazz"), ); } tui.state.show_rooms = true; let _ = screen_of(tui); } /// The queries the window has sent so far, once at least `count` of them /// are out: a search goes to the session from a worker thread, so a test /// has to give it a moment. fn searched(session: &TalkativeSession, count: usize) -> Vec { let deadline = std::time::Instant::now() + Duration::from_secs(5); loop { let seen = session.searched.lock().expect("not poisoned").clone(); if seen.len() >= count || std::time::Instant::now() > deadline { return seen; } std::thread::sleep(Duration::from_millis(10)); } } /// A window over last session's search list: the queries came back from /// disk, with nothing under them. fn restored(session: Arc, queries: &[&str]) -> MainTui { let mut tui = attach(session); let queries: Vec = queries.iter().map(ToString::to_string).collect(); restore_searches(&mut tui.state, &queries); tui.state.focused_pane = FocusedPane::Searches; tui }