use super::MainTui; use crate::models::{CommandBarMode, FocusedPane, PaneLayout, RoomsView}; use crate::ui::panes::{ InfoSubject, ResultsPaneParams, render_browse_pane, render_chat_pane, render_download_info_pane, render_downloads_pane, render_results_pane, render_rooms_pane, render_searches_pane, selected_transfer, }; use crate::ui::{ GLYPH_CURSOR, accent_style, body_style, dimmed_style, info_style, mask, pack_shortcuts, pane_block, plain_title, primary_style, render_download_stats, warning_style, }; use ratatui::{ Frame, layout::{Alignment, Constraint, Layout, Position, Rect}, style::Modifier, text::{Line, Span}, widgets::Paragraph, }; /// 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"), ("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"), ("/", "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"), ], ), ]; /// 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::Share(_) => ("d", "remove share"), }, ("a", "add share"), ("r", "re-index"), ("Esc", "close"), ], } } impl MainTui { /// Draw the whole window. pub fn render(&mut self, frame: &mut Frame) { let shortcuts = self.shortcut_rows(frame.area().width); let mut constraints = vec![ Constraint::Length(3), // Status bar Constraint::Fill(1), // Main content ]; if self.state.command_bar_active { constraints.push(Constraint::Length(3)); // Command bar } constraints.push(Constraint::Length(shortcuts.len() as u16 + 2)); let main_chunks = Layout::vertical(constraints).split(frame.area()); let daemon = self.client.daemon_endpoint(); render_download_stats( frame, main_chunks[0], &self.state.downloads, self.state.active_downloads_count, daemon.as_deref(), ); self.render_content(frame, main_chunks[1]); // Render command bar if active (vim-style, above shortcuts) if self.state.command_bar_active { self.render_command_bar(frame, main_chunks[2]); self.render_shortcuts(frame, main_chunks[3], shortcuts); } else { self.render_shortcuts(frame, main_chunks[2], shortcuts); } self.render_overlays(frame); } /// Results across the top, where a long file name has the whole width; /// Searches, Downloads and Info in a row underneath. A hidden pane drops /// out of its row and the rest widen; a zoomed one has the area alone. fn render_content(&mut self, frame: &mut Frame, area: Rect) { // Only what is drawn can be clicked. self.state.searches_pane_area = None; self.state.results_pane_area = None; self.state.downloads_pane_area = None; self.state.info_pane_area = None; self.state.hsplit_divider = None; self.state.vsplit_dividers.clear(); self.state.row_area = None; if self.state.layout.zoomed { self.render_pane(frame, area, self.state.focused_pane); return; } self.state.content_area = Some(area); // Transfers carry the most columns, so they get the most width. let row_panes: Vec<(FocusedPane, u16)> = [(FocusedPane::Searches, 5), (FocusedPane::Downloads, 10)] .into_iter() .filter(|(pane, _)| self.state.layout.is_visible(*pane)) .collect(); let row_area = if self.state.layout.is_visible(FocusedPane::Results) { // With only Info left in the row, the results deserve more of // the height. let row_weight = if row_panes.is_empty() { 1 } else { 2 }; let top = match self.state.layout.top_height { Some(rows) => Constraint::Length(shrink(rows, area.height)), None => Constraint::Fill(3), }; let [top, bottom] = Layout::vertical([top, Constraint::Fill(row_weight)]) .areas(area); // The two touching border rows between the halves; either can // be dragged. self.state.hsplit_divider = Some(Rect::new( area.x, top.y.saturating_add(top.height).saturating_sub(1), area.width, 2, )); self.render_pane(frame, top, FocusedPane::Results); bottom } else { area }; self.state.row_area = Some(row_area); let mut constraints: Vec = row_panes .iter() .map(|(pane, weight)| { let dragged = match *pane { FocusedPane::Searches => self.state.layout.searches_width, FocusedPane::Downloads => self.state.layout.downloads_width, FocusedPane::Results => None, }; match dragged { Some(cols) => { Constraint::Length(shrink(cols, row_area.width)) } None => Constraint::Fill(*weight), } }) .collect(); constraints.push(Constraint::Fill(6)); // Info let chunks = Layout::horizontal(constraints).split(row_area); for (chunk, (pane, _)) in chunks.iter().zip(&row_panes) { // The seam at this pane's right edge: its border column and the // next pane's, both draggable. self.state.vsplit_dividers.push(( *pane, Rect::new( chunk.x.saturating_add(chunk.width).saturating_sub(1), row_area.y, 2, row_area.height, ), )); self.render_pane(frame, *chunk, *pane); } self.state.info_pane_area = Some(chunks[row_panes.len()]); self.render_info_pane(frame, chunks[row_panes.len()]); } fn render_pane( &mut self, frame: &mut Frame, area: Rect, pane: FocusedPane, ) { let focused = self.state.focused_pane == pane; match pane { FocusedPane::Searches => { self.state.searches_pane_area = Some(area); render_searches_pane( frame, area, &self.state.searches, &mut self.state.searches_table_state, focused, self.state.searches_query_offset, ); } FocusedPane::Results => { self.state.results_pane_area = Some(area); self.render_results_pane(frame, area, focused); } FocusedPane::Downloads => { self.state.downloads_pane_area = Some(area); render_downloads_pane( frame, area, &self.state.downloads, &self.state.uploads, &mut self.state.downloads_table_state, focused, self.state.downloads_name_offset, ); } } } fn render_results_pane( &mut self, frame: &mut Frame, area: Rect, focused: bool, ) { // When a filter is active the rendered rows are a subset, so the pane // also needs the mapping back to unfiltered indices to render the // selection checkboxes correctly. let (results_items, results_original_indices) = if self.state.results_filter_query.is_empty() { (&self.state.results_items, None) } else { ( &self.state.results_filtered_items, Some(self.state.results_filtered_indices.as_slice()), ) }; let active_search_query = self .state .selected_search_index .and_then(|idx| self.state.searches.get(idx)) .map(|search| search.query.as_str()); render_results_pane( frame, area, ResultsPaneParams { items: results_items, table_state: &mut self.state.results_table_state, selected_indices: &self.state.results_selected_indices, original_indices: results_original_indices, filter_query: &self.state.results_filter_query, is_filtering: self.state.results_is_filtering, focused, active_search_query, name_offset: self.state.results_name_offset, }, ); } /// The Info pane follows the focus: the highlighted result, or the /// highlighted transfer. fn render_info_pane(&self, frame: &mut Frame, area: Rect) { let selected = if self.state.focused_pane == FocusedPane::Results { self.highlighted_result().map(InfoSubject::Result) } else { selected_transfer( self.state.downloads_table_state.selected(), &self.state.downloads, &self.state.uploads, ) }; render_download_info_pane( frame, area, selected, matches!( self.state.focused_pane, FocusedPane::Results | FocusedPane::Downloads ), ); } /// 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. 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, ); } } fn render_overlays(&mut self, frame: &mut Frame) { // Messages inbox overlays everything when open. if self.state.show_messages { self.render_messages_popup(frame); } // Browse tree overlays everything when open. if self.state.show_browse && !self.state.browse.is_empty() { let area = centered_rect(80, 80, frame.area()); self.state.popup_area = Some(area); frame.render_widget(ratatui::widgets::Clear, area); render_browse_pane( frame, area, &self.state.browse, &mut self.state.browse_table_state, self.spinner_state, &self.state.downloads, ); } // Settings popup overlays everything when open. if self.state.settings.is_some() { self.render_settings_popup(frame); } // Chat rooms overlay everything when open. if self.state.show_rooms { let area = centered_rect(85, 80, frame.area()); self.state.popup_area = Some(area); frame.render_widget(ratatui::widgets::Clear, area); render_rooms_pane( frame, area, &mut self.state.rooms, &mut self.state.rooms_list_table_state, ); } // The keys list only opens from the main view, so it is on top of // nothing; drawn last so that stays true if that ever changes. if self.state.show_help { self.render_help_popup(frame); } } fn render_settings_popup(&self, frame: &mut Frame) { use crate::models::{SettingsMode, SettingsRow}; let Some(settings) = self.state.settings.as_ref() else { return; }; let area = centered_rect(70, 60, frame.area()); frame.render_widget(ratatui::widgets::Clear, area); let selected = settings.selected_row(); let mut lines: Vec = Vec::new(); let entry = |on: bool, label: &str, value: String| { Line::from(vec![ Span::styled(if on { "> " } else { " " }, accent_style()), Span::styled(label.to_string(), dimmed_style()), Span::styled(value, primary_style()), ]) }; let typing = |label: &str, value: String| { Line::from(vec![ Span::styled("> ", accent_style()), Span::styled(label.to_string(), dimmed_style()), Span::styled(value, primary_style()), Span::styled(GLYPH_CURSOR, accent_style()), ]) }; let section = |name: &str| Line::styled(name.to_string(), info_style()); // One column of labels, padded here rather than by hand in each // string. let field = |label: &str, value: String| { entry(false, &format!(" {label:<14}"), value) }; let account = &settings.account; lines.push(section("Account")); lines.push(field("Signed in as", account.username.clone())); lines.push(field("Sharing", account.shares.clone())); lines.push(field( "Reachable on", account.listen_port.map_or_else( || "not accepting connections".to_string(), |port| format!("port {port}"), ), )); if let Some(daemon) = &account.daemon { lines.push(field("Daemon", daemon.clone())); } lines.push(Line::from("")); lines.push(match settings.mode { SettingsMode::NewPassword => { typing("New password: ", mask(&settings.input)) } SettingsMode::RepeatPassword(_) => { typing("Repeat it: ", mask(&settings.input)) } _ => entry( selected == SettingsRow::ChangePassword, "Change password…", String::new(), ), }); if settings.mode == SettingsMode::ConfirmingLogout { lines.push(entry( true, &format!("Log out of {}? ", account.username), "y / n".to_string(), )); } else if settings.can_log_out() { lines.push(entry( selected == SettingsRow::Logout, "Log out…", String::new(), )); } lines.push(Line::from("")); lines.push(section("Folders")); lines.push(if settings.mode == SettingsMode::EditingDownloadDir { typing("Download folder: ", settings.input.clone()) } else { entry( selected == SettingsRow::DownloadDir, "Download folder: ", settings.download_dir.clone(), ) }); lines.push(Line::styled( format!("Shared folders ({}):", settings.share_dirs.len()), dimmed_style(), )); for (i, dir) in settings.share_dirs.iter().enumerate() { lines.push(entry( selected == SettingsRow::Share(i), "", dir.clone(), )); } if settings.share_dirs.is_empty() { lines.push(Line::styled( " (nothing shared — press 'a' to add a folder)", dimmed_style(), )); } if settings.mode == SettingsMode::AddingShare { lines.push(typing(" Add share: ", settings.input.clone())); } if let Some(status) = &settings.status { lines.push(Line::from("")); lines.push(Line::styled(status.clone(), warning_style())); } let block = pane_block(true).title(plain_title("Settings", true)); frame.render_widget( ratatui::widgets::Paragraph::new(lines) .block(block) .wrap(ratatui::widgets::Wrap { trim: false }), area, ); } fn render_messages_popup(&mut self, frame: &mut Frame) { // The per-conversation chat box supersedes the old flat message list; // it renders the messages and the compose line itself. let area = centered_rect(70, 60, frame.area()); frame.render_widget(ratatui::widgets::Clear, area); self.state.popup_area = Some(area); let own = self.client.username(); render_chat_pane(frame, area, &mut self.state, &own); } /// 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 { vec![ ("↑↓", "move"), ("J/K", "next/prev folder"), ("→←", "expand/collapse"), ("H/L", "collapse/expand all"), ("/", "filter"), ("Enter", "open/download"), ("d", "download folder"), ("Tab", "switch user"), ("r", "retry"), ("w", "close tab"), ("Esc", "hide"), ] } } 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"), ("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. 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 }; pack_shortcuts(&self.shortcuts(), width.saturating_sub(reserved)) } /// 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, ) } 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], ); } } fn render_command_bar(&self, frame: &mut Frame, area: Rect) { let prefix = match self.state.command_bar_mode { CommandBarMode::Search => "search: ", CommandBarMode::Message => "message (to: recipient text): ", CommandBarMode::Browse => "browse user: ", }; let block = pane_block(true); // Derive from the block's inner rect so borders and padding stay in // one place; the cursor follows whatever the pane reserves. let inner = block.inner(area); let prefix_width = prefix.chars().count() as u16; let input_width = inner.width.saturating_sub(prefix_width); let (visible_input, cursor_column) = visible_input_at_cursor( &self.state.command_bar_input, self.state.command_bar_cursor_position, input_width, ); let command_line = Line::from(vec![ Span::styled(prefix, accent_style()), Span::styled(visible_input, primary_style()), ]); frame.render_widget(Paragraph::new(command_line).block(block), area); if inner.width > 0 && inner.height > 0 { let cursor_x = inner .x .saturating_add(prefix_width) .saturating_add(cursor_column) .min(inner.x.saturating_add(inner.width.saturating_sub(1))); frame.set_cursor_position(Position::new(cursor_x, inner.y)); } } } /// Columns between the two halves of the keys list. const HELP_GAP: u16 = 2; /// A dragged pane size kept inside the space the terminal now offers: never /// below [`MIN_PANE`], and never so big that what shares the split with it /// drops below `MIN_PANE` too. pub(super) fn shrink(dragged: u16, total: u16) -> u16 { let max = total.saturating_sub(MIN_PANE).max(MIN_PANE); dragged.max(MIN_PANE).min(max) } /// The smallest pane a drag can leave anyone: a border and a row or column /// of content each side. pub(super) const MIN_PANE: u16 = 3; /// 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: Rect { let vertical = Layout::vertical([ Constraint::Percentage((100 - percent_y) / 2), Constraint::Percentage(percent_y), Constraint::Percentage((100 - percent_y) / 2), ]) .split(area); Layout::horizontal([ Constraint::Percentage((100 - percent_x) / 2), Constraint::Percentage(percent_x), Constraint::Percentage((100 - percent_x) / 2), ]) .split(vertical[1])[1] } /// Build the shortcuts-bar unread badge: `✉ N` for unread private messages and /// `💬 N` for unread chat-room messages. Returns an empty line when nothing is /// unread so the caller can skip it. /// /// `bright` drives a self-drawn blink: the caller flips it on a timer so the /// badge pulses between bold warning colour and dim. We do the blink ourselves /// rather than lean on the terminal's SLOW_BLINK attribute, which tmux and most /// macOS terminals silently drop. The text is identical in both phases, so the /// badge keeps its width and the bar never reflows. fn unread_indicator( messages: usize, rooms: usize, bright: bool, ) -> 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)) } fn visible_input_at_cursor( input: &str, cursor_position: usize, width: u16, ) -> (String, u16) { if width == 0 { return (String::new(), 0); } let cursor_position = input.floor_char_boundary(cursor_position); let cursor_character_index = input[..cursor_position].chars().count(); let max_cursor_column = usize::from(width.saturating_sub(1)); let start_character_index = cursor_character_index.saturating_sub(max_cursor_column); let visible_input = input .chars() .skip(start_character_index) .take(usize::from(width)) .collect(); let cursor_column = cursor_character_index .saturating_sub(start_character_index) .min(max_cursor_column); (visible_input, cursor_column as u16) } #[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" ); } }