diff --git a/README.md b/README.md index caca11a..973252b 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,8 @@ In the interactive TUI, press `c` to open the chat-rooms popup: - several rooms can be **open at once** as tabs — `Tab`/`Shift-Tab` switch between them, `x` leaves the active room, `l` returns to the room list; - in a room, press `Enter` to type a message and `Enter` again to send; +- the room's **member list** is selectable with `↑`/`↓`; press `b` to browse + the highlighted user's shared files or `m` to send them a private message; - **unread messages** bold a room's tab and add a `room (n)` badge, and the `c chat (n)` shortcut counts unread across all open rooms. diff --git a/soulseek-rs/src/models/rooms.rs b/soulseek-rs/src/models/rooms.rs index 1b8ecae..01913ec 100644 --- a/soulseek-rs/src/models/rooms.rs +++ b/soulseek-rs/src/models/rooms.rs @@ -69,6 +69,8 @@ pub struct RoomsState { pub view: RoomsView, /// Whether the compose input for the active room is capturing keys. pub composing: bool, + /// Selected index into the active room's member list (for browse/PM). + pub user_selected: usize, } impl Default for RoomsState { @@ -89,6 +91,7 @@ impl RoomsState { active: 0, view: RoomsView::List, composing: false, + user_selected: 0, } } @@ -133,6 +136,7 @@ impl RoomsState { /// `true` if the room was newly opened (so the caller should `join_room`). pub fn focus_or_open(&mut self, name: &str) -> bool { self.view = RoomsView::Chat; + self.user_selected = 0; if let Some(idx) = self.open_index(name) { self.active = idx; self.mark_active_read(); @@ -144,6 +148,26 @@ impl RoomsState { } } + /// The username highlighted in the active room's member list, if any. + #[must_use] + pub fn selected_user(&self) -> Option { + self.active_room() + .and_then(|r| r.users.get(self.user_selected).cloned()) + } + + pub const fn select_user_up(&mut self) { + self.user_selected = self.user_selected.saturating_sub(1); + } + + pub fn select_user_down(&mut self) { + if let Some(room) = self.active_room() + && !room.users.is_empty() + { + self.user_selected = + (self.user_selected + 1).min(room.users.len() - 1); + } + } + /// Close the active tab, returning its room name (for `leave_room`). pub fn close_active(&mut self) -> Option { if self.active >= self.open.len() { @@ -153,6 +177,7 @@ impl RoomsState { if self.active >= self.open.len() { self.active = self.open.len().saturating_sub(1); } + self.user_selected = 0; if self.open.is_empty() { self.view = RoomsView::List; } else { @@ -165,6 +190,7 @@ impl RoomsState { pub fn next_tab(&mut self) { if !self.open.is_empty() { self.active = (self.active + 1) % self.open.len(); + self.user_selected = 0; self.mark_active_read(); } } @@ -172,6 +198,7 @@ impl RoomsState { pub fn prev_tab(&mut self) { if !self.open.is_empty() { self.active = (self.active + self.open.len() - 1) % self.open.len(); + self.user_selected = 0; self.mark_active_read(); } } @@ -469,6 +496,36 @@ mod tests { assert_eq!(state.total_unread(), 0); } + #[test] + fn user_selection_navigates_and_resets_across_tabs() { + let mut state = RoomsState::new(); + state.apply_event( + RoomEvent::Joined { + room: "jazz".to_string(), + users: vec![ + "alice".to_string(), + "bob".to_string(), + "carol".to_string(), + ], + }, + None, + ); + state.focus_or_open("jazz"); + assert_eq!(state.selected_user().as_deref(), Some("alice")); + state.select_user_down(); + state.select_user_down(); + assert_eq!(state.selected_user().as_deref(), Some("carol")); + state.select_user_down(); // clamps at the last user + assert_eq!(state.selected_user().as_deref(), Some("carol")); + state.select_user_up(); + assert_eq!(state.selected_user().as_deref(), Some("bob")); + + // Opening another room resets the user highlight. + state.focus_or_open("blues"); + assert_eq!(state.user_selected, 0); + assert_eq!(state.selected_user(), None); // blues has no members yet + } + #[test] fn user_joined_and_left_update_membership() { let mut state = RoomsState::new(); diff --git a/soulseek-rs/src/ui/main_tui.rs b/soulseek-rs/src/ui/main_tui.rs index 1bade8a..44df1bb 100644 --- a/soulseek-rs/src/ui/main_tui.rs +++ b/soulseek-rs/src/ui/main_tui.rs @@ -356,11 +356,13 @@ impl MainTui { } } RoomsView::Chat => vec![ - ("Enter", "type message"), + ("Enter", "say"), + ("↑↓", "pick user"), + ("b", "browse user"), + ("m", "message user"), ("Tab", "switch room"), - ("l", "room list"), + ("l", "rooms"), ("x", "leave"), - ("q", "close"), ], } } @@ -840,6 +842,15 @@ impl MainTui { KeyCode::Tab => self.state.rooms.next_tab(), KeyCode::BackTab => self.state.rooms.prev_tab(), KeyCode::Char('x') => self.leave_active_room(), + KeyCode::Up | KeyCode::Char('k') => { + self.state.rooms.select_user_up(); + } + KeyCode::Down | KeyCode::Char('j') => { + self.state.rooms.select_user_down(); + } + // Act on the highlighted member of the room. + KeyCode::Char('b') => self.browse_selected_room_user(), + KeyCode::Char('m') => self.message_selected_room_user(), KeyCode::Enter if self.state.rooms.active_room().is_some() => { self.state.rooms.composing = true; } @@ -847,6 +858,29 @@ impl MainTui { } } + /// Browse the shares of the member highlighted in the active room. Closes + /// the rooms popup so the browse tree is the sole overlay. + fn browse_selected_room_user(&mut self) { + if let Some(user) = self.state.rooms.selected_user() { + self.state.show_rooms = false; + self.start_browse(user); + } + } + + /// Compose a private message to the member highlighted in the active room. + /// Closes the rooms popup and opens the message command bar pre-filled with + /// the recipient. + fn message_selected_room_user(&mut self) { + if let Some(user) = self.state.rooms.selected_user() { + self.state.show_rooms = false; + self.state.command_bar_active = true; + self.state.command_bar_mode = CommandBarMode::Message; + self.state.command_bar_input = format!("{user} "); + self.state.command_bar_cursor_position = + self.state.command_bar_input.len(); + } + } + fn handle_room_compose_input(&mut self, key: KeyEvent) { match key.code { KeyCode::Enter => self.send_room_message(), diff --git a/soulseek-rs/src/ui/panes/rooms_pane.rs b/soulseek-rs/src/ui/panes/rooms_pane.rs index 74a15ca..d7775e3 100644 --- a/soulseek-rs/src/ui/panes/rooms_pane.rs +++ b/soulseek-rs/src/ui/panes/rooms_pane.rs @@ -132,7 +132,7 @@ fn render_chat(frame: &mut Frame, area: Rect, rooms: &RoomsState) { .split(chunks[1]); render_messages(frame, body[0], active.lines.as_slice()); - render_users(frame, body[1], &active.users); + render_users(frame, body[1], &active.users, rooms.user_selected); // Compose line or hint. if rooms.composing { @@ -200,7 +200,12 @@ fn render_messages( frame.render_widget(Paragraph::new(rendered), area); } -fn render_users(frame: &mut Frame, area: Rect, users: &[String]) { +fn render_users( + frame: &mut Frame, + area: Rect, + users: &[String], + selected: usize, +) { let block = Block::default() .borders(Borders::LEFT) .border_style(dimmed_style()) @@ -209,10 +214,20 @@ fn render_users(frame: &mut Frame, area: Rect, users: &[String]) { frame.render_widget(block, area); let height = inner.height as usize; + // Keep the highlighted user in view by scrolling the window to it. + let start = selected.saturating_sub(height.saturating_sub(1)); let shown: Vec = users .iter() + .enumerate() + .skip(start) .take(height) - .map(|u| Line::from(Span::styled(u.clone(), primary_style()))) + .map(|(i, u)| { + if i == selected { + Line::from(Span::styled(format!("▸ {u}"), highlight_style())) + } else { + Line::from(Span::styled(format!(" {u}"), primary_style())) + } + }) .collect(); frame.render_widget(Paragraph::new(shown), inner); }