//! The keys the window advertises: the shortcut bar along the foot, the `?` //! overlay behind it, and the unread badge that rides in the bar. //! //! A child module of `render`, so it still reaches the window's state and //! `render`'s own helpers directly; only what `render` calls back into is //! widened to `pub(super)`. use super::{ Alignment, CommandBarMode, Constraint, FocusedPane, Frame, Layout, Line, MainTui, Modifier, PaneLayout, Paragraph, Rect, RoomsView, Span, accent_style, body_style, dimmed_style, info_style, pack_shortcuts, pane_block, plain_title, warning_style, }; /// The keys overlay, grouped by where a key applies. Two columns, because a /// single list runs longer than most terminals are tall. const HELP_LEFT: &[(&str, &[(&str, &str)])] = &[ ( "Panes", &[ ("Tab / Shift-Tab", "next / previous pane"), ("1 2 3", "focus a pane, hidden or not"), ("z", "zoom the focused pane"), ("w", "hide the focused pane"), ("Esc", "leave zoom"), ("W", "every pane back, zoom off, sizes reset"), ("click", "focus a pane"), ("drag", "resize panes by their borders"), ], ), ( "Anywhere", &[ ("s", "search the network"), ("b", "browse a user's files"), ("B", "browse what you share yourself"), ("m", "compose a private message"), ("i", "inbox"), ("o", "settings"), ("?", "this list"), ("q", "quit"), ], ), ( "Any list", &[ ("↑ ↓ / k j", "move one row"), ("Home End / g G", "first / last row"), ("PgUp PgDn / ^b ^f", "one page"), ("^u ^d", "half a page"), ("h l / ← →", "scroll a long name sideways"), ("0 / $", "its start / its end"), ], ), ( "Settings popup", &[ ("Enter", "change the password, log out, edit the folder"), ("a / d", "add / remove a shared folder"), ("r", "re-index the shares"), ], ), ]; const HELP_RIGHT: &[(&str, &[(&str, &str)])] = &[ ( "Searches", &[ ("Enter", "show its results"), ("S", "run the search again"), ("d", "remove the search"), ("C", "clear every search"), ("c", "chat rooms"), ], ), ( "Results", &[ ("Space", "select / deselect"), ("a / A", "select all / none"), ("Enter", "download the selection"), ("D", "download highlighted file's containing folder"), ("/", "filter, Enter keeps it, Esc clears"), ("b", "browse the owner"), ("c", "chat rooms"), ], ), ( "Downloads / Uploads", &[ ("p", "pause / resume"), ("x", "cancel"), ("r", "retry a failed one"), ("d", "delete a queued or finished one"), ("c", "clear every finished one"), ("C", "clear every one, cancelling the live ones"), ("b", "browse that user"), ], ), ( "Chat and browse popups", &[ ("PgUp PgDn / ^b ^f", "a page of messages, or of the tree"), ("^u ^d", "half a page of them"), ("Home End / g G", "oldest / newest, or first / last row"), ("Tab / Shift-Tab", "next room, chat or user"), ("/", "filter the messages, the room list or the tree"), ("u", "filter a room's member list"), ], ), ( "Browse popup", &[ ("← → / h l", "close / open a folder, or step out / in"), ("J K", "next / previous folder"), ("H L", "close / open every folder"), ("/", "filter by path, Enter keeps it, Esc clears"), ("Enter", "open a folder, or download a file"), ("d", "download a file, or a folder's files"), ("r", "ask again after a timeout, re-index your own"), ("o", "shared folders (your own tab)"), ], ), ]; /// What the bar offers while a filter is being typed, wherever that is, /// with the keys that still move around there. fn filter_keys( moves: &[(&'static str, &'static str)], ) -> Vec<(&'static str, &'static str)> { let mut keys = vec![("Type", "filter")]; keys.extend_from_slice(moves); keys.extend([("Enter", "keep filter"), ("Esc", "clear filter")]); keys } /// What the bar offers in the settings popup: the keys that always apply, /// plus what Enter does on the row the selection rests on. fn settings_shortcuts( settings: &crate::models::SettingsState, ) -> Vec<(&'static str, &'static str)> { use crate::models::{SettingsMode, SettingsRow}; match settings.mode { SettingsMode::ConfirmingLogout => { vec![("y", "log out"), ("n/Esc", "stay")] } SettingsMode::NewPassword => { vec![ ("Type", "password"), ("Enter", "repeat it"), ("Esc", "cancel"), ] } SettingsMode::RepeatPassword(_) => { vec![ ("Type", "password"), ("Enter", "change it"), ("Esc", "cancel"), ] } SettingsMode::EditingDownloadDir | SettingsMode::AddingShare => { vec![("Type", "path"), ("Enter", "save"), ("Esc", "cancel")] } SettingsMode::Navigate => vec![ ("↑↓", "move"), match settings.selected_row() { SettingsRow::ChangePassword => ("Enter", "change password"), SettingsRow::Logout => ("Enter", "log out"), SettingsRow::DownloadDir => ("Enter", "edit folder"), SettingsRow::UseUsernameDirs => ("Enter", "toggle option"), SettingsRow::Share(_) => ("d", "remove share"), }, ("a", "add share"), ("r", "re-index"), ("Esc", "close"), ], } } impl MainTui { /// The keys list, two columns side by side where the terminal is wide /// enough and one on top of the other where it is not, scrolling when /// even that is taller than the window. pub(super) fn render_help_popup(&mut self, frame: &mut Frame) { let screen = frame.area(); // Borders and padding on either side of the text. let frame_width = 4; let columns = help_columns(screen.width.saturating_sub(frame_width)); let gaps = HELP_GAP * (columns.len() as u16 - 1); let text_width = columns.iter().map(|lines| widest(lines)).sum::(); let width = screen.width.min(text_width + gaps + frame_width); let tallest = columns.iter().map(Vec::len).max().unwrap_or(0); let height = screen .height .min(u16::try_from(tallest + 2).unwrap_or(u16::MAX)); let area = Rect::new( screen.x + (screen.width - width) / 2, screen.y + (screen.height - height) / 2, width, height, ); frame.render_widget(ratatui::widgets::Clear, area); let block = pane_block(true).title(plain_title("Keys", true)); let inner = block.inner(area); frame.render_widget(block, area); // Both columns scroll together, and no further than the taller one // needs. let window = self .state .help_view .window(tallest, usize::from(inner.height)); let scroll = u16::try_from(window.start).unwrap_or(u16::MAX); let areas = Layout::horizontal(vec![Constraint::Fill(1); columns.len()]) .spacing(HELP_GAP) .split(inner); for (area, lines) in areas.iter().zip(columns) { frame.render_widget( Paragraph::new(lines).scroll((scroll, 0)), *area, ); } } /// Context shortcuts for the chat-rooms popup. fn rooms_shortcuts(&self) -> Vec<(&'static str, &'static str)> { if self.state.rooms.composing { return vec![ ("Type", "message"), ("Enter", "send"), ("Esc", "cancel"), ]; } match self.state.rooms.view { RoomsView::List => { if self.state.rooms.list_is_filtering { vec![ ("Type", "filter"), ("Enter", "join match"), ("Esc", "clear filter"), ] } else { vec![ ("↑↓", "move"), ("Enter", "join"), ("/", "filter"), ("Tab", "open rooms"), ("Esc", "close"), ] } } RoomsView::Chat if self.state.rooms.filtering.is_some() => { filter_keys(&[("PgUp/PgDn", "scroll"), ("↑↓", "pick user")]) } RoomsView::Chat => vec![ ("Enter", "say"), ("PgUp/PgDn", "scroll"), ("↑↓", "pick user"), ("/", "find in chat"), ("u", "find user"), ("b", "browse user"), ("m", "message user"), ("Tab", "switch room"), ("l", "rooms"), ("x", "leave"), ], } } /// The keys every pane shares for moving between and resizing panes, /// ending the bar the same way wherever the focus sits. fn pane_shortcuts(&self) -> Vec<(&'static str, &'static str)> { let mut keys = vec![ ("Tab/1-3", "pane"), ( "z", if self.state.layout.zoomed { "unzoom" } else { "zoom" }, ), ("w", "hide"), ]; // Only once a pane is hidden or zoomed is there a layout to reset. if self.state.layout != PaneLayout::default() { keys.push(("W", "reset layout")); } keys.extend([("?", "keys"), ("q", "quit")]); keys } /// Which shortcuts the bar offers, which is purely a question of what is /// open and where the focus sits. Sibling of [`Self::rooms_shortcuts`]. fn shortcuts(&self) -> Vec<(&'static str, &'static str)> { if self.state.show_help { vec![("?/Esc", "close")] } else if let Some(settings) = self.state.settings.as_ref() { settings_shortcuts(settings) } else if self.state.show_messages { if self.state.chat_composing { vec![ ("Type", "message"), ("Enter", "send"), ("Esc", "stop typing"), ] } else if self.state.chat_filtering { filter_keys(&[("PgUp/PgDn", "scroll")]) } else { vec![ ("Enter", "type"), ("PgUp/PgDn", "scroll"), ("↑↓/Tab", "switch chat"), ("/", "find"), ("m", "new chat"), ("i/Esc", "close"), ] } } else if self.state.show_rooms { self.rooms_shortcuts() } else if self.state.show_browse { if self.state.browse.active_tab().is_some_and(|b| b.filtering) { // Typing goes to the filter, so no letter keys are on offer. filter_keys(&[("↑/↓", "navigate"), ("PgUp/PgDn", "page")]) } else { // Nothing of ours is downloadable from ourselves, and `r` // re-scans the disk instead of re-asking a peer. let acts = if self.state.browse.active_is_own() { [ ("Enter", "open folder"), ("r", "re-index"), ("o", "shared folders"), ] } else { [ ("Enter", "open/download"), ("d", "download folder"), ("r", "retry"), ] }; let mut keys = vec![ ("↑↓", "move"), ("J/K", "next/prev folder"), ("→←", "expand/collapse"), ("H/L", "collapse/expand all"), ("/", "filter"), ]; keys.extend(acts); keys.extend([ ("Tab", "switch user"), ("w", "close tab"), ("Esc", "hide"), ]); keys } } else if self.state.command_bar_active { match self.state.command_bar_mode { CommandBarMode::Search => vec![ ("Type", "search term"), ("←→", "move cursor"), ("Backspace/Del", "edit"), ("Enter", "search"), ("Esc", "cancel"), ], CommandBarMode::Message => vec![ ("Type", "recipient then message"), ("Enter", "send"), ("Esc", "cancel"), ], CommandBarMode::Browse => vec![ ("Type", "username"), ("Enter", "browse"), ("Esc", "cancel"), ], } } else if self.state.results_is_filtering && self.state.focused_pane == FocusedPane::Results { // Typing goes to the filter, so no letter keys are on offer here. filter_keys(&[("↑/↓", "navigate"), ("PgUp/PgDn", "page")]) } else { let mut keys = match self.state.focused_pane { FocusedPane::Searches => vec![ ("s", "search"), ("S", "search again"), ("Enter", "results"), ("d", "remove"), ("C", "clear all"), ("m", "message"), ("i", "inbox"), ("c", "chat"), ("b", "browse user"), ("h/l", "scroll query"), ], FocusedPane::Results => vec![ ("Space", "select"), ("Enter", "download"), ("D", "download containing folder"), ("b", "browse owner"), ("c", "chat"), ("/", "filter"), ("a/A", "select all/none"), ("h/l", "scroll name"), ("g/G", "top/end"), ], FocusedPane::Downloads => vec![ ("p", "pause/resume"), ("x", "cancel"), ("r", "retry failed"), ("d", "delete queued/done"), ("c", "clear finished"), ("C", "clear all"), ("b", "browse user"), ("h/l", "scroll name"), ], }; keys.extend(self.pane_shortcuts()); keys } } /// The legend packed into rows for a window `width` wide, so a narrow /// terminal grows the bar instead of cutting keys off its right edge. pub(super) fn shortcut_rows(&self, width: u16) -> Vec> { // Borders and padding, and the unread badge's column when it shows. let badge = self.unread_badge().width() as u16; let reserved = 4 + if badge > 0 { badge + 1 } else { 0 }; let mut rows = pack_shortcuts(&self.shortcuts(), width.saturating_sub(reserved)); if let Some(status) = &self.folder_download_status { rows.push(Line::styled(status.clone(), info_style())); } rows } /// A prominent unread badge for the shortcuts bar. It rides in the /// always-visible bar, so unread mail/chat shows in every pane, not just /// some. The spinner tick (0..10, ~100ms each) drives a ~1s blink: bright /// for the first half of the cycle, dim for the second. fn unread_badge(&self) -> Line<'static> { unread_indicator( self.state.unread_messages, self.state.rooms.total_unread(), self.spinner_state < 5, ) } pub(super) fn render_shortcuts( &self, frame: &mut Frame, area: Rect, rows: Vec>, ) { // Surface our own sharing status in the block title. let shared = self.client.shared_directories(); let sharing = match shared.as_slice() { [] => "off".to_string(), [only] => only.clone(), more => format!("{} folders", more.len()), }; let title = format!("Shortcuts · Sharing: {sharing}"); // Themed block (master's design system), but we take its inner area so // the unread badge can be split off to the right below. let block = pane_block(false).title(plain_title(title, false)); let inner = block.inner(area); frame.render_widget(block, area); // The unread badge is right-aligned so the context shortcuts on the // left never crowd it out. let unread = self.unread_badge(); if unread.spans.is_empty() { frame.render_widget(Paragraph::new(rows), inner); } else { let badge_width = unread.width() as u16; let cols = Layout::horizontal([ Constraint::Fill(1), Constraint::Length(badge_width), ]) .spacing(1) .split(inner); frame.render_widget(Paragraph::new(rows), cols[0]); frame.render_widget( Paragraph::new(unread).alignment(Alignment::Right), cols[1], ); } } } /// Columns between the two halves of the keys list. const HELP_GAP: u16 = 2; /// The widest key in a column, so the descriptions line up. fn key_column_width(sections: &[(&str, &[(&str, &str)])]) -> usize { sections .iter() .flat_map(|(_, keys)| keys.iter()) .map(|(key, _)| Span::raw(*key).width()) .max() .unwrap_or(0) } /// Cells the widest of `lines` takes. fn widest(lines: &[Line<'_>]) -> u16 { let cells = lines.iter().map(Line::width).max().unwrap_or(0); u16::try_from(cells).unwrap_or(u16::MAX) } /// The keys list laid out for `width` cells of text: two columns when both /// fit beside each other, otherwise the right half under the left. fn help_columns(width: u16) -> Vec>> { let left = help_lines(HELP_LEFT); let right = help_lines(HELP_RIGHT); if width >= widest(&left) + HELP_GAP + widest(&right) { return vec![left, right]; } let mut stacked = left; stacked.push(Line::from("")); stacked.extend(right); vec![stacked] } fn help_lines(sections: &[(&str, &[(&str, &str)])]) -> Vec> { let key_width = key_column_width(sections); let mut lines = Vec::new(); for (index, (heading, keys)) in sections.iter().enumerate() { if index > 0 { lines.push(Line::from("")); } lines.push(Line::styled( (*heading).to_string(), accent_style().add_modifier(Modifier::BOLD), )); for (key, action) in *keys { lines.push(Line::from(vec![ Span::styled(format!("{key: Line<'static> { let mut counts: Vec = Vec::new(); if messages > 0 { counts.push(format!("✉ {messages}")); } if rooms > 0 { counts.push(format!("💬 {rooms}")); } if counts.is_empty() { return Line::from(Vec::new()); } let style = if bright { warning_style().add_modifier(Modifier::BOLD) } else { dimmed_style() }; Line::from(Span::styled(format!("⟨ {} ⟩", counts.join(" ")), style)) } #[cfg(test)] mod tests { use super::unread_indicator; fn text(messages: usize, rooms: usize) -> String { unread_indicator(messages, rooms, true) .spans .iter() .map(|s| s.content.as_ref()) .collect() } #[test] fn unread_indicator_is_empty_when_nothing_is_unread() { // Empty in both blink phases — no badge means no badge. assert!(unread_indicator(0, 0, true).spans.is_empty()); assert!(unread_indicator(0, 0, false).spans.is_empty()); } #[test] fn unread_indicator_shows_each_source_and_both_together() { // Bracketed so the badge reads as one distinct block. assert_eq!(text(3, 0), "⟨ ✉ 3 ⟩"); assert_eq!(text(0, 2), "⟨ 💬 2 ⟩"); // Both present: DMs first, rooms second, separated so they don't run // together. assert_eq!(text(3, 2), "⟨ ✉ 3 💬 2 ⟩"); } #[test] fn blink_phases_share_text_but_differ_in_style() { // The self-drawn blink must keep the same glyphs (so the bar never // reflows) while changing style, or it would not read as blinking. let bright = unread_indicator(1, 0, true); let dim = unread_indicator(1, 0, false); assert_eq!(bright.spans[0].content, dim.spans[0].content); assert_ne!( bright.spans[0].style, dim.spans[0].style, "bright and dim phases must look different" ); } }