| 1 | //! Searchable help overlay for `?`, `F1`, and `Ctrl+/`. |
| 2 | //! |
| 3 | //! Renders two stacked sections — *Slash commands* and *Keybindings* — with |
| 4 | //! a live substring filter applied as the user types in the search box. The |
| 5 | //! command list is sourced from [`crate::commands::COMMANDS`] and the |
| 6 | //! keybinding list from [`crate::tui::keybindings::KEYBINDINGS`] so neither |
| 7 | //! can drift from the wired-up handlers. |
| 8 | //! |
| 9 | //! Keys: any printable character extends the filter, `Backspace` shrinks it, |
| 10 | //! `↑`/`↓` (or `Ctrl+P`/`Ctrl+N`) move the selection, `PgUp`/`PgDn` jump by |
| 11 | //! ten rows, `Home`/`End` jump to ends, and `Esc` closes. Pressing `?` again |
| 12 | //! at the call-site (`tui::ui`) also toggles the overlay closed. |
| 13 | |
| 14 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; |
| 15 | use ratatui::{ |
| 16 | buffer::Buffer, |
| 17 | layout::Rect, |
| 18 | style::{Modifier, Style}, |
| 19 | text::{Line, Span}, |
| 20 | widgets::{Block, Borders, Clear, Padding, Paragraph, Widget}, |
| 21 | }; |
| 22 | use unicode_width::UnicodeWidthStr; |
| 23 | |
| 24 | use crate::commands; |
| 25 | use crate::localization::{Locale, MessageId, tr}; |
| 26 | use crate::palette; |
| 27 | use crate::tui::keybindings::KEYBINDINGS; |
| 28 | use crate::tui::views::{ModalKind, ModalView, ViewAction}; |
| 29 | |
| 30 | /// Two top-level sections rendered in the overlay. |
| 31 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 32 | enum HelpSection { |
| 33 | Command, |
| 34 | Keybinding, |
| 35 | } |
| 36 | |
| 37 | impl HelpSection { |
| 38 | fn label(self, locale: Locale) -> &'static str { |
| 39 | match self { |
| 40 | Self::Command => tr(locale, MessageId::HelpSlashCommands), |
| 41 | Self::Keybinding => tr(locale, MessageId::HelpKeybindings), |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | /// Sort key — commands before keybindings keeps the most-used surface up |
| 46 | /// top so an unfiltered overlay opens with the user's likely target in |
| 47 | /// view without scrolling. |
| 48 | fn rank(self) -> u8 { |
| 49 | match self { |
| 50 | Self::Command => 0, |
| 51 | Self::Keybinding => 1, |
| 52 | } |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | #[derive(Debug, Clone)] |
| 57 | struct HelpEntry { |
| 58 | section: HelpSection, |
| 59 | /// Sort-within-section key — keybinding entries reuse their declared |
| 60 | /// section's rank so the help overlay groups Navigation, Editing, … in |
| 61 | /// the same order as `tui::keybindings`. |
| 62 | sub_rank: u8, |
| 63 | label: String, |
| 64 | description: String, |
| 65 | /// Lowercased haystack used for substring matching; pre-built so each |
| 66 | /// keystroke does not re-allocate per entry. |
| 67 | haystack: String, |
| 68 | } |
| 69 | |
| 70 | pub struct HelpView { |
| 71 | locale: Locale, |
| 72 | entries: Vec<HelpEntry>, |
| 73 | /// Indices into `entries`, in display order, after filtering. |
| 74 | filtered: Vec<usize>, |
| 75 | query: String, |
| 76 | selected: usize, |
| 77 | } |
| 78 | |
| 79 | impl Default for HelpView { |
| 80 | fn default() -> Self { |
| 81 | Self::new() |
| 82 | } |
| 83 | } |
| 84 | |
| 85 | impl HelpView { |
| 86 | pub fn new() -> Self { |
| 87 | Self::new_for_locale(Locale::En) |
| 88 | } |
| 89 | |
| 90 | pub fn new_for_locale(locale: Locale) -> Self { |
| 91 | let entries = build_entries(locale); |
| 92 | let mut view = Self { |
| 93 | locale, |
| 94 | entries, |
| 95 | filtered: Vec::new(), |
| 96 | query: String::new(), |
| 97 | selected: 0, |
| 98 | }; |
| 99 | view.refilter(); |
| 100 | view |
| 101 | } |
| 102 | |
| 103 | fn tr(&self, id: MessageId) -> &'static str { |
| 104 | tr(self.locale, id) |
| 105 | } |
| 106 | |
| 107 | fn refilter(&mut self) { |
| 108 | // Substring matching is intentional — fuzzy matchers can hide the |
| 109 | // exact-prefix hit a user is typing toward, which is the wrong |
| 110 | // failure mode for a *help* surface. We split on whitespace so |
| 111 | // multi-term queries (`apply mode`) act as an AND. |
| 112 | let query = self.query.trim().to_ascii_lowercase(); |
| 113 | let terms: Vec<&str> = query |
| 114 | .split_whitespace() |
| 115 | .filter(|term| !term.is_empty()) |
| 116 | .collect(); |
| 117 | |
| 118 | let mut filtered: Vec<usize> = self |
| 119 | .entries |
| 120 | .iter() |
| 121 | .enumerate() |
| 122 | .filter(|(_, entry)| terms.iter().all(|term| entry.haystack.contains(term))) |
| 123 | .map(|(idx, _)| idx) |
| 124 | .collect(); |
| 125 | |
| 126 | filtered.sort_by_key(|idx| { |
| 127 | let entry = &self.entries[*idx]; |
| 128 | (entry.section.rank(), entry.sub_rank, entry.label.clone()) |
| 129 | }); |
| 130 | self.filtered = filtered; |
| 131 | if self.selected >= self.filtered.len() { |
| 132 | self.selected = self.filtered.len().saturating_sub(1); |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | fn move_selection(&mut self, delta: isize) { |
| 137 | if self.filtered.is_empty() { |
| 138 | self.selected = 0; |
| 139 | return; |
| 140 | } |
| 141 | let len = self.filtered.len() as isize; |
| 142 | let next = (self.selected as isize + delta).clamp(0, len - 1) as usize; |
| 143 | self.selected = next; |
| 144 | } |
| 145 | } |
| 146 | |
| 147 | fn build_entries(locale: Locale) -> Vec<HelpEntry> { |
| 148 | let mut entries = Vec::new(); |
| 149 | |
| 150 | for command in commands::COMMANDS { |
| 151 | let label = format!("/{}", command.name); |
| 152 | let localized = command.description_for(locale); |
| 153 | let description = if command.aliases.is_empty() { |
| 154 | localized.to_string() |
| 155 | } else { |
| 156 | format!( |
| 157 | "{} (aliases: {})", |
| 158 | localized, |
| 159 | command |
| 160 | .aliases |
| 161 | .iter() |
| 162 | .map(|a| format!("/{a}")) |
| 163 | .collect::<Vec<_>>() |
| 164 | .join(", ") |
| 165 | ) |
| 166 | }; |
| 167 | let haystack = format!( |
| 168 | "{} {} {}", |
| 169 | label.to_ascii_lowercase(), |
| 170 | description.to_ascii_lowercase(), |
| 171 | command.usage.to_ascii_lowercase() |
| 172 | ); |
| 173 | entries.push(HelpEntry { |
| 174 | section: HelpSection::Command, |
| 175 | // Commands have no inherent ordering — fall back to alphabetical |
| 176 | // by leaning on `label.clone()` in the final sort_by_key tuple. |
| 177 | sub_rank: 0, |
| 178 | label, |
| 179 | description, |
| 180 | haystack, |
| 181 | }); |
| 182 | } |
| 183 | |
| 184 | for binding in KEYBINDINGS { |
| 185 | let label = binding.chord.to_string(); |
| 186 | let description = format!( |
| 187 | "[{}] {}", |
| 188 | binding.section.label(locale), |
| 189 | tr(locale, binding.description_id) |
| 190 | ); |
| 191 | let haystack = format!( |
| 192 | "{} {}", |
| 193 | label.to_ascii_lowercase(), |
| 194 | description.to_ascii_lowercase() |
| 195 | ); |
| 196 | entries.push(HelpEntry { |
| 197 | section: HelpSection::Keybinding, |
| 198 | sub_rank: binding.section.rank(), |
| 199 | label, |
| 200 | description, |
| 201 | haystack, |
| 202 | }); |
| 203 | } |
| 204 | |
| 205 | entries |
| 206 | } |
| 207 | |
| 208 | fn modal_block() -> Block<'static> { |
| 209 | Block::default() |
| 210 | .borders(Borders::ALL) |
| 211 | .border_style(Style::default().fg(palette::BORDER_COLOR)) |
| 212 | .style(Style::default().bg(palette::DEEPSEEK_INK)) |
| 213 | .padding(Padding::uniform(1)) |
| 214 | } |
| 215 | |
| 216 | fn truncate_to_width(text: &str, max_width: usize) -> String { |
| 217 | if max_width == 0 { |
| 218 | return String::new(); |
| 219 | } |
| 220 | if text.width() <= max_width { |
| 221 | return text.to_string(); |
| 222 | } |
| 223 | let mut out = String::new(); |
| 224 | let limit = max_width.saturating_sub(1); |
| 225 | for ch in text.chars() { |
| 226 | let next_width = out.width() + ch.to_string().width(); |
| 227 | if next_width > limit { |
| 228 | break; |
| 229 | } |
| 230 | out.push(ch); |
| 231 | } |
| 232 | out.push('…'); |
| 233 | out |
| 234 | } |
| 235 | |
| 236 | impl ModalView for HelpView { |
| 237 | fn kind(&self) -> ModalKind { |
| 238 | ModalKind::Help |
| 239 | } |
| 240 | |
| 241 | fn as_any_mut(&mut self) -> &mut dyn std::any::Any { |
| 242 | self |
| 243 | } |
| 244 | |
| 245 | fn handle_key(&mut self, key: KeyEvent) -> ViewAction { |
| 246 | match key.code { |
| 247 | KeyCode::Esc => ViewAction::Close, |
| 248 | KeyCode::Up => { |
| 249 | self.move_selection(-1); |
| 250 | ViewAction::None |
| 251 | } |
| 252 | KeyCode::Down => { |
| 253 | self.move_selection(1); |
| 254 | ViewAction::None |
| 255 | } |
| 256 | KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => { |
| 257 | self.move_selection(-1); |
| 258 | ViewAction::None |
| 259 | } |
| 260 | KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => { |
| 261 | self.move_selection(1); |
| 262 | ViewAction::None |
| 263 | } |
| 264 | KeyCode::PageUp => { |
| 265 | self.move_selection(-10); |
| 266 | ViewAction::None |
| 267 | } |
| 268 | KeyCode::PageDown => { |
| 269 | self.move_selection(10); |
| 270 | ViewAction::None |
| 271 | } |
| 272 | KeyCode::Home => { |
| 273 | self.selected = 0; |
| 274 | ViewAction::None |
| 275 | } |
| 276 | KeyCode::End => { |
| 277 | if !self.filtered.is_empty() { |
| 278 | self.selected = self.filtered.len() - 1; |
| 279 | } |
| 280 | ViewAction::None |
| 281 | } |
| 282 | KeyCode::Backspace => { |
| 283 | self.query.pop(); |
| 284 | self.refilter(); |
| 285 | ViewAction::None |
| 286 | } |
| 287 | KeyCode::Char(c) |
| 288 | if !c.is_control() |
| 289 | && (key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT) => |
| 290 | { |
| 291 | self.query.push(c); |
| 292 | self.refilter(); |
| 293 | ViewAction::None |
| 294 | } |
| 295 | _ => ViewAction::None, |
| 296 | } |
| 297 | } |
| 298 | |
| 299 | fn render(&self, area: Rect, buf: &mut Buffer) { |
| 300 | let popup_width = 90.min(area.width.saturating_sub(4)); |
| 301 | let popup_height = 28.min(area.height.saturating_sub(4)); |
| 302 | let popup_area = Rect { |
| 303 | x: area.width.saturating_sub(popup_width) / 2, |
| 304 | y: area.height.saturating_sub(popup_height) / 2, |
| 305 | width: popup_width, |
| 306 | height: popup_height, |
| 307 | }; |
| 308 | |
| 309 | Clear.render(popup_area, buf); |
| 310 | |
| 311 | let mut lines: Vec<Line<'static>> = Vec::new(); |
| 312 | |
| 313 | let query_label = if self.query.is_empty() { |
| 314 | self.tr(MessageId::HelpFilterPlaceholder).to_string() |
| 315 | } else { |
| 316 | format!("{}{}", self.tr(MessageId::HelpFilterPrefix), self.query) |
| 317 | }; |
| 318 | lines.push(Line::from(Span::styled( |
| 319 | query_label, |
| 320 | Style::default() |
| 321 | .fg(palette::DEEPSEEK_SKY) |
| 322 | .add_modifier(Modifier::BOLD), |
| 323 | ))); |
| 324 | |
| 325 | let match_count = if self.query.is_empty() { |
| 326 | format!("{} entries", self.entries.len()) |
| 327 | } else { |
| 328 | format!("{} / {} matches", self.filtered.len(), self.entries.len()) |
| 329 | }; |
| 330 | lines.push(Line::from(Span::styled( |
| 331 | match_count, |
| 332 | Style::default() |
| 333 | .fg(palette::TEXT_DIM) |
| 334 | .add_modifier(Modifier::ITALIC), |
| 335 | ))); |
| 336 | lines.push(Line::from("")); |
| 337 | |
| 338 | if self.filtered.is_empty() { |
| 339 | lines.push(Line::from(Span::styled( |
| 340 | self.tr(MessageId::HelpNoMatches), |
| 341 | Style::default() |
| 342 | .fg(palette::TEXT_MUTED) |
| 343 | .add_modifier(Modifier::ITALIC), |
| 344 | ))); |
| 345 | } else { |
| 346 | // The chord/label column takes up to 28 cols on wide screens; |
| 347 | // descriptions fill the remainder. Borders and padding eat 4 |
| 348 | // cells from each side (border 1 + padding 1) × 2. |
| 349 | let inner_width = popup_width.saturating_sub(4) as usize; |
| 350 | let label_width = 28.min(inner_width.saturating_sub(8)); |
| 351 | let desc_capacity = inner_width.saturating_sub(label_width + 4); |
| 352 | |
| 353 | // Visible window: header (3) + footer hint (handled by block); |
| 354 | // budget the remaining rows for entries and inserted section |
| 355 | // headings. Section headings can push us past the budget on tiny |
| 356 | // terminals — we still render them because losing the heading is |
| 357 | // worse than losing one trailing row of entries. |
| 358 | let header_lines = lines.len(); |
| 359 | let visible_budget = (popup_height as usize) |
| 360 | .saturating_sub(header_lines + 3) |
| 361 | .max(1); |
| 362 | |
| 363 | // Centre the selected row in the visible window when it is far |
| 364 | // down, otherwise keep the natural top-aligned listing. |
| 365 | let scroll = self |
| 366 | .selected |
| 367 | .saturating_sub(visible_budget.saturating_sub(1)); |
| 368 | let mut active_section: Option<HelpSection> = None; |
| 369 | let mut rendered_rows = 0usize; |
| 370 | |
| 371 | for (slot, idx) in self.filtered.iter().enumerate() { |
| 372 | if slot < scroll { |
| 373 | continue; |
| 374 | } |
| 375 | if rendered_rows >= visible_budget { |
| 376 | break; |
| 377 | } |
| 378 | |
| 379 | let entry = &self.entries[*idx]; |
| 380 | if active_section != Some(entry.section) { |
| 381 | if rendered_rows > 0 { |
| 382 | lines.push(Line::from("")); |
| 383 | rendered_rows += 1; |
| 384 | } |
| 385 | let count = self |
| 386 | .filtered |
| 387 | .iter() |
| 388 | .filter(|idx| self.entries[**idx].section == entry.section) |
| 389 | .count(); |
| 390 | lines.push(Line::from(Span::styled( |
| 391 | format!(" {} ({})", entry.section.label(self.locale), count), |
| 392 | Style::default() |
| 393 | .fg(palette::DEEPSEEK_BLUE) |
| 394 | .add_modifier(Modifier::BOLD), |
| 395 | ))); |
| 396 | rendered_rows += 1; |
| 397 | active_section = Some(entry.section); |
| 398 | if rendered_rows >= visible_budget { |
| 399 | break; |
| 400 | } |
| 401 | } |
| 402 | |
| 403 | let is_selected = slot == self.selected; |
| 404 | let style = if is_selected { |
| 405 | Style::default() |
| 406 | .fg(palette::SELECTION_TEXT) |
| 407 | .bg(palette::SELECTION_BG) |
| 408 | } else { |
| 409 | Style::default().fg(palette::TEXT_PRIMARY) |
| 410 | }; |
| 411 | let cursor = if is_selected { "▶ " } else { " " }; |
| 412 | let label = truncate_to_width(&entry.label, label_width); |
| 413 | let desc = truncate_to_width(&entry.description, desc_capacity); |
| 414 | let line_text = format!("{cursor}{label:<label_width$} {desc}", label = label,); |
| 415 | lines.push(Line::from(Span::styled(line_text, style))); |
| 416 | rendered_rows += 1; |
| 417 | } |
| 418 | } |
| 419 | |
| 420 | let block = modal_block() |
| 421 | .title(Line::from(vec![Span::styled( |
| 422 | format!(" {} ", self.tr(MessageId::HelpTitle)), |
| 423 | Style::default() |
| 424 | .fg(palette::DEEPSEEK_BLUE) |
| 425 | .add_modifier(Modifier::BOLD), |
| 426 | )])) |
| 427 | .title_bottom(Line::from(vec![ |
| 428 | Span::styled( |
| 429 | self.tr(MessageId::HelpFooterTypeFilter), |
| 430 | Style::default().fg(palette::TEXT_MUTED), |
| 431 | ), |
| 432 | Span::styled( |
| 433 | self.tr(MessageId::HelpFooterMove), |
| 434 | Style::default().fg(palette::TEXT_MUTED), |
| 435 | ), |
| 436 | Span::styled( |
| 437 | self.tr(MessageId::HelpFooterJump), |
| 438 | Style::default().fg(palette::TEXT_MUTED), |
| 439 | ), |
| 440 | Span::styled( |
| 441 | self.tr(MessageId::HelpFooterClose), |
| 442 | Style::default().fg(palette::TEXT_MUTED), |
| 443 | ), |
| 444 | ])); |
| 445 | |
| 446 | Paragraph::new(lines).block(block).render(popup_area, buf); |
| 447 | } |
| 448 | } |
| 449 | |
| 450 | #[cfg(test)] |
| 451 | mod tests { |
| 452 | use super::*; |
| 453 | use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; |
| 454 | |
| 455 | fn key(code: KeyCode) -> KeyEvent { |
| 456 | KeyEvent::new(code, KeyModifiers::NONE) |
| 457 | } |
| 458 | |
| 459 | fn type_filter(view: &mut HelpView, text: &str) { |
| 460 | for ch in text.chars() { |
| 461 | view.handle_key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)); |
| 462 | } |
| 463 | } |
| 464 | |
| 465 | #[test] |
| 466 | fn empty_filter_lists_all_entries() { |
| 467 | let view = HelpView::new(); |
| 468 | // Total = registered slash commands + catalogued keybindings. |
| 469 | let expected = commands::COMMANDS.len() + KEYBINDINGS.len(); |
| 470 | assert_eq!(view.filtered.len(), expected); |
| 471 | assert_eq!(view.entries.len(), expected); |
| 472 | } |
| 473 | |
| 474 | #[test] |
| 475 | fn substring_filter_narrows_to_command() { |
| 476 | let mut view = HelpView::new(); |
| 477 | type_filter(&mut view, "yolo"); |
| 478 | assert!(!view.filtered.is_empty()); |
| 479 | // Every filtered entry should genuinely contain the query in its |
| 480 | // searchable haystack — no false positives slipped past. |
| 481 | for idx in &view.filtered { |
| 482 | assert!( |
| 483 | view.entries[*idx].haystack.contains("yolo"), |
| 484 | "entry {:?} leaked through `yolo` filter", |
| 485 | view.entries[*idx] |
| 486 | ); |
| 487 | } |
| 488 | // The `/yolo` command must survive the filter; it's the canonical |
| 489 | // single-term match. |
| 490 | assert!( |
| 491 | view.filtered |
| 492 | .iter() |
| 493 | .any(|idx| view.entries[*idx].label == "/yolo"), |
| 494 | "/yolo should match the `yolo` filter" |
| 495 | ); |
| 496 | } |
| 497 | |
| 498 | #[test] |
| 499 | fn substring_filter_finds_keybinding_by_chord() { |
| 500 | let mut view = HelpView::new(); |
| 501 | type_filter(&mut view, "ctrl+r"); |
| 502 | assert!(!view.filtered.is_empty(), "Ctrl+R should match"); |
| 503 | assert!( |
| 504 | view.filtered |
| 505 | .iter() |
| 506 | .any(|idx| view.entries[*idx].label.eq_ignore_ascii_case("ctrl+r")), |
| 507 | "Ctrl+R chord must surface in the filtered set" |
| 508 | ); |
| 509 | } |
| 510 | |
| 511 | #[test] |
| 512 | fn multiple_terms_act_as_and() { |
| 513 | let mut view = HelpView::new(); |
| 514 | type_filter(&mut view, "session picker"); |
| 515 | assert!( |
| 516 | !view.filtered.is_empty(), |
| 517 | "expected at least one entry mentioning both `session` and `picker`" |
| 518 | ); |
| 519 | for idx in &view.filtered { |
| 520 | let haystack = &view.entries[*idx].haystack; |
| 521 | assert!( |
| 522 | haystack.contains("session") && haystack.contains("picker"), |
| 523 | "entry {:?} leaked through `session picker` AND filter", |
| 524 | view.entries[*idx] |
| 525 | ); |
| 526 | } |
| 527 | } |
| 528 | |
| 529 | #[test] |
| 530 | fn unknown_filter_yields_empty_set() { |
| 531 | let mut view = HelpView::new(); |
| 532 | type_filter(&mut view, "zzzqqxxnope"); |
| 533 | assert!(view.filtered.is_empty()); |
| 534 | assert_eq!(view.selected, 0); |
| 535 | } |
| 536 | |
| 537 | #[test] |
| 538 | fn backspace_widens_match_set() { |
| 539 | let mut view = HelpView::new(); |
| 540 | type_filter(&mut view, "yolox"); |
| 541 | let narrow = view.filtered.len(); |
| 542 | view.handle_key(key(KeyCode::Backspace)); |
| 543 | let wider = view.filtered.len(); |
| 544 | assert!( |
| 545 | wider > narrow, |
| 546 | "backspace must broaden the matching set (was {narrow}, now {wider})" |
| 547 | ); |
| 548 | } |
| 549 | |
| 550 | #[test] |
| 551 | fn esc_closes_overlay() { |
| 552 | let mut view = HelpView::new(); |
| 553 | let action = view.handle_key(key(KeyCode::Esc)); |
| 554 | assert!(matches!(action, ViewAction::Close)); |
| 555 | } |
| 556 | |
| 557 | #[test] |
| 558 | fn arrow_keys_move_selection_within_bounds() { |
| 559 | let mut view = HelpView::new(); |
| 560 | // Down once → row 1; Up twice → clamped at 0. |
| 561 | view.handle_key(key(KeyCode::Down)); |
| 562 | assert_eq!(view.selected, 1); |
| 563 | view.handle_key(key(KeyCode::Up)); |
| 564 | view.handle_key(key(KeyCode::Up)); |
| 565 | assert_eq!(view.selected, 0); |
| 566 | // End → last row. |
| 567 | view.handle_key(key(KeyCode::End)); |
| 568 | assert_eq!(view.selected, view.filtered.len() - 1); |
| 569 | } |
| 570 | |
| 571 | #[test] |
| 572 | fn render_includes_help_chrome_for_empty_filter() { |
| 573 | let view = HelpView::new(); |
| 574 | let area = Rect::new(0, 0, 96, 32); |
| 575 | let mut buf = Buffer::empty(area); |
| 576 | view.render(area, &mut buf); |
| 577 | |
| 578 | let dump = buffer_text(&buf, area); |
| 579 | // Title border + section headings should always render. |
| 580 | assert!(dump.contains("Help"), "missing help title:\n{dump}"); |
| 581 | assert!( |
| 582 | dump.contains("Type to filter"), |
| 583 | "missing filter prompt:\n{dump}" |
| 584 | ); |
| 585 | assert!( |
| 586 | dump.contains("Slash commands"), |
| 587 | "missing slash-command section heading:\n{dump}" |
| 588 | ); |
| 589 | // Footer hint should advertise close key on the bottom border. |
| 590 | assert!( |
| 591 | dump.contains("Esc close"), |
| 592 | "missing Esc close footer hint:\n{dump}" |
| 593 | ); |
| 594 | } |
| 595 | |
| 596 | #[test] |
| 597 | fn render_with_filter_shows_only_matching_section_and_status() { |
| 598 | let mut view = HelpView::new(); |
| 599 | type_filter(&mut view, "yolo"); |
| 600 | let area = Rect::new(0, 0, 96, 24); |
| 601 | let mut buf = Buffer::empty(area); |
| 602 | view.render(area, &mut buf); |
| 603 | |
| 604 | let dump = buffer_text(&buf, area); |
| 605 | assert!( |
| 606 | dump.contains("Filter: yolo"), |
| 607 | "filter echo missing:\n{dump}" |
| 608 | ); |
| 609 | assert!( |
| 610 | dump.contains("matches"), |
| 611 | "match counter missing in dump:\n{dump}" |
| 612 | ); |
| 613 | assert!( |
| 614 | dump.contains("/yolo"), |
| 615 | "expected /yolo command in filtered render:\n{dump}" |
| 616 | ); |
| 617 | assert!( |
| 618 | !dump.contains("/agent"), |
| 619 | "non-matching commands should not render under a `yolo` filter:\n{dump}" |
| 620 | ); |
| 621 | } |
| 622 | |
| 623 | #[test] |
| 624 | fn localized_help_chrome_renders_without_missing_markers() { |
| 625 | let view = HelpView::new_for_locale(Locale::ZhHans); |
| 626 | let area = Rect::new(0, 0, 48, 18); |
| 627 | let mut buf = Buffer::empty(area); |
| 628 | view.render(area, &mut buf); |
| 629 | |
| 630 | let dump = buffer_text(&buf, area); |
| 631 | assert!( |
| 632 | dump.contains('帮') && dump.contains('助'), |
| 633 | "missing localized title:\n{dump}" |
| 634 | ); |
| 635 | assert!( |
| 636 | !dump.contains("MISSING"), |
| 637 | "missing-key marker leaked:\n{dump}" |
| 638 | ); |
| 639 | } |
| 640 | |
| 641 | #[test] |
| 642 | fn localized_help_keybinding_descriptions_use_zh_hans() { |
| 643 | let entries = build_entries(Locale::ZhHans); |
| 644 | let kb_entries: Vec<_> = entries |
| 645 | .iter() |
| 646 | .filter(|e| e.section == HelpSection::Keybinding) |
| 647 | .collect(); |
| 648 | assert!(!kb_entries.is_empty(), "no keybinding entries found"); |
| 649 | |
| 650 | for entry in &kb_entries { |
| 651 | assert!( |
| 652 | entry |
| 653 | .description |
| 654 | .chars() |
| 655 | .any(|c| { ('\u{4e00}'..='\u{9fff}').contains(&c) }), |
| 656 | "keybinding description not localized: {}", |
| 657 | entry.description |
| 658 | ); |
| 659 | } |
| 660 | } |
| 661 | |
| 662 | fn buffer_text(buf: &Buffer, area: Rect) -> String { |
| 663 | let mut out = String::new(); |
| 664 | for y in area.top()..area.bottom() { |
| 665 | for x in area.left()..area.right() { |
| 666 | out.push_str(buf[(x, y)].symbol()); |
| 667 | } |
| 668 | out.push('\n'); |
| 669 | } |
| 670 | out |
| 671 | } |
| 672 | } |
| 673 |