feat(tui): browse or PM a user from a chat room's member list
The member list in an open room is now selectable with the arrow keys. Press 'b' to browse the highlighted user's shared files (opens the browse tree) or 'm' to send them a private message (opens the compose bar pre-filled with the recipient). Selection resets when switching rooms; RoomsState navigation is unit-tested.
This commit is contained in:
@@ -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
|
- 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;
|
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;
|
- 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
|
- **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.
|
`c chat (n)` shortcut counts unread across all open rooms.
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ pub struct RoomsState {
|
|||||||
pub view: RoomsView,
|
pub view: RoomsView,
|
||||||
/// Whether the compose input for the active room is capturing keys.
|
/// Whether the compose input for the active room is capturing keys.
|
||||||
pub composing: bool,
|
pub composing: bool,
|
||||||
|
/// Selected index into the active room's member list (for browse/PM).
|
||||||
|
pub user_selected: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for RoomsState {
|
impl Default for RoomsState {
|
||||||
@@ -89,6 +91,7 @@ impl RoomsState {
|
|||||||
active: 0,
|
active: 0,
|
||||||
view: RoomsView::List,
|
view: RoomsView::List,
|
||||||
composing: false,
|
composing: false,
|
||||||
|
user_selected: 0,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,6 +136,7 @@ impl RoomsState {
|
|||||||
/// `true` if the room was newly opened (so the caller should `join_room`).
|
/// `true` if the room was newly opened (so the caller should `join_room`).
|
||||||
pub fn focus_or_open(&mut self, name: &str) -> bool {
|
pub fn focus_or_open(&mut self, name: &str) -> bool {
|
||||||
self.view = RoomsView::Chat;
|
self.view = RoomsView::Chat;
|
||||||
|
self.user_selected = 0;
|
||||||
if let Some(idx) = self.open_index(name) {
|
if let Some(idx) = self.open_index(name) {
|
||||||
self.active = idx;
|
self.active = idx;
|
||||||
self.mark_active_read();
|
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<String> {
|
||||||
|
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`).
|
/// Close the active tab, returning its room name (for `leave_room`).
|
||||||
pub fn close_active(&mut self) -> Option<String> {
|
pub fn close_active(&mut self) -> Option<String> {
|
||||||
if self.active >= self.open.len() {
|
if self.active >= self.open.len() {
|
||||||
@@ -153,6 +177,7 @@ impl RoomsState {
|
|||||||
if self.active >= self.open.len() {
|
if self.active >= self.open.len() {
|
||||||
self.active = self.open.len().saturating_sub(1);
|
self.active = self.open.len().saturating_sub(1);
|
||||||
}
|
}
|
||||||
|
self.user_selected = 0;
|
||||||
if self.open.is_empty() {
|
if self.open.is_empty() {
|
||||||
self.view = RoomsView::List;
|
self.view = RoomsView::List;
|
||||||
} else {
|
} else {
|
||||||
@@ -165,6 +190,7 @@ impl RoomsState {
|
|||||||
pub fn next_tab(&mut self) {
|
pub fn next_tab(&mut self) {
|
||||||
if !self.open.is_empty() {
|
if !self.open.is_empty() {
|
||||||
self.active = (self.active + 1) % self.open.len();
|
self.active = (self.active + 1) % self.open.len();
|
||||||
|
self.user_selected = 0;
|
||||||
self.mark_active_read();
|
self.mark_active_read();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,6 +198,7 @@ impl RoomsState {
|
|||||||
pub fn prev_tab(&mut self) {
|
pub fn prev_tab(&mut self) {
|
||||||
if !self.open.is_empty() {
|
if !self.open.is_empty() {
|
||||||
self.active = (self.active + self.open.len() - 1) % self.open.len();
|
self.active = (self.active + self.open.len() - 1) % self.open.len();
|
||||||
|
self.user_selected = 0;
|
||||||
self.mark_active_read();
|
self.mark_active_read();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -469,6 +496,36 @@ mod tests {
|
|||||||
assert_eq!(state.total_unread(), 0);
|
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]
|
#[test]
|
||||||
fn user_joined_and_left_update_membership() {
|
fn user_joined_and_left_update_membership() {
|
||||||
let mut state = RoomsState::new();
|
let mut state = RoomsState::new();
|
||||||
|
|||||||
@@ -356,11 +356,13 @@ impl MainTui {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
RoomsView::Chat => vec![
|
RoomsView::Chat => vec![
|
||||||
("Enter", "type message"),
|
("Enter", "say"),
|
||||||
|
("↑↓", "pick user"),
|
||||||
|
("b", "browse user"),
|
||||||
|
("m", "message user"),
|
||||||
("Tab", "switch room"),
|
("Tab", "switch room"),
|
||||||
("l", "room list"),
|
("l", "rooms"),
|
||||||
("x", "leave"),
|
("x", "leave"),
|
||||||
("q", "close"),
|
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -840,6 +842,15 @@ impl MainTui {
|
|||||||
KeyCode::Tab => self.state.rooms.next_tab(),
|
KeyCode::Tab => self.state.rooms.next_tab(),
|
||||||
KeyCode::BackTab => self.state.rooms.prev_tab(),
|
KeyCode::BackTab => self.state.rooms.prev_tab(),
|
||||||
KeyCode::Char('x') => self.leave_active_room(),
|
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() => {
|
KeyCode::Enter if self.state.rooms.active_room().is_some() => {
|
||||||
self.state.rooms.composing = true;
|
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) {
|
fn handle_room_compose_input(&mut self, key: KeyEvent) {
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Enter => self.send_room_message(),
|
KeyCode::Enter => self.send_room_message(),
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ fn render_chat(frame: &mut Frame, area: Rect, rooms: &RoomsState) {
|
|||||||
.split(chunks[1]);
|
.split(chunks[1]);
|
||||||
|
|
||||||
render_messages(frame, body[0], active.lines.as_slice());
|
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.
|
// Compose line or hint.
|
||||||
if rooms.composing {
|
if rooms.composing {
|
||||||
@@ -200,7 +200,12 @@ fn render_messages(
|
|||||||
frame.render_widget(Paragraph::new(rendered), area);
|
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()
|
let block = Block::default()
|
||||||
.borders(Borders::LEFT)
|
.borders(Borders::LEFT)
|
||||||
.border_style(dimmed_style())
|
.border_style(dimmed_style())
|
||||||
@@ -209,10 +214,20 @@ fn render_users(frame: &mut Frame, area: Rect, users: &[String]) {
|
|||||||
frame.render_widget(block, area);
|
frame.render_widget(block, area);
|
||||||
|
|
||||||
let height = inner.height as usize;
|
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<Line> = users
|
let shown: Vec<Line> = users
|
||||||
.iter()
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.skip(start)
|
||||||
.take(height)
|
.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();
|
.collect();
|
||||||
frame.render_widget(Paragraph::new(shown), inner);
|
frame.render_widget(Paragraph::new(shown), inner);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user