diff --git a/crates/tui/src/tui/views/help.rs b/crates/tui/src/tui/views/help.rs index dda016af..bc8437a5 100644 --- a/crates/tui/src/tui/views/help.rs +++ b/crates/tui/src/tui/views/help.rs @@ -68,6 +68,12 @@ struct HelpEntry { haystack: String, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HelpRenderRow { + Section(HelpSection), + Entry { slot: usize, entry_idx: usize }, +} + pub struct HelpView { locale: Locale, entries: Vec, @@ -143,6 +149,54 @@ impl HelpView { let next = (self.selected as isize + delta).clamp(0, len - 1) as usize; self.selected = next; } + + fn move_selection_wrapping(&mut self, delta: isize) { + if self.filtered.is_empty() { + self.selected = 0; + return; + } + let len = self.filtered.len() as isize; + let next = (self.selected as isize + delta).rem_euclid(len) as usize; + self.selected = next; + } + + fn render_rows(&self) -> Vec { + let mut rows = Vec::new(); + let mut active_section: Option = None; + + for (slot, entry_idx) in self.filtered.iter().copied().enumerate() { + let entry = &self.entries[entry_idx]; + if active_section != Some(entry.section) { + rows.push(HelpRenderRow::Section(entry.section)); + active_section = Some(entry.section); + } + rows.push(HelpRenderRow::Entry { slot, entry_idx }); + } + + rows + } + + fn selected_render_row(rows: &[HelpRenderRow], selected: usize) -> usize { + rows.iter() + .position(|row| matches!(row, HelpRenderRow::Entry { slot, .. } if *slot == selected)) + .unwrap_or(0) + } + + fn visible_row_start(rows: &[HelpRenderRow], selected: usize, visible_budget: usize) -> usize { + if rows.len() <= visible_budget { + return 0; + } + + let selected_row = Self::selected_render_row(rows, selected); + let half = visible_budget / 2; + if selected_row <= half { + 0 + } else if selected_row + half >= rows.len() { + rows.len().saturating_sub(visible_budget) + } else { + selected_row.saturating_sub(half) + } + } } fn build_entries(locale: Locale) -> Vec { @@ -251,19 +305,19 @@ impl ModalView for HelpView { } KeyCode::Char('q') | KeyCode::Char('Q') if self.query.is_empty() => ViewAction::Close, KeyCode::Up => { - self.move_selection(-1); + self.move_selection_wrapping(-1); ViewAction::None } KeyCode::Down => { - self.move_selection(1); + self.move_selection_wrapping(1); ViewAction::None } KeyCode::Char('p') if key.modifiers.contains(KeyModifiers::CONTROL) => { - self.move_selection(-1); + self.move_selection_wrapping(-1); ViewAction::None } KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => { - self.move_selection(1); + self.move_selection_wrapping(1); ViewAction::None } KeyCode::PageUp => { @@ -363,70 +417,53 @@ impl ModalView for HelpView { let label_width = 28.min(inner_width.saturating_sub(8)); let desc_capacity = inner_width.saturating_sub(label_width + 4); - // Visible window: header (3) + footer hint (handled by block); - // budget the remaining rows for entries and inserted section - // headings. Section headings can push us past the budget on tiny - // terminals — we still render them because losing the heading is - // worse than losing one trailing row of entries. + // The block uses a one-cell border plus one-cell padding, so the + // real paragraph body is four rows shorter than the outer popup. + // Budget against that body height so selected rows are not clipped + // by the bottom border/padding. let header_lines = lines.len(); let visible_budget = (popup_height as usize) - .saturating_sub(header_lines + 3) + .saturating_sub(4) + .saturating_sub(header_lines) .max(1); - // Centre the selected row in the visible window when it is far - // down, otherwise keep the natural top-aligned listing. - let scroll = self - .selected - .saturating_sub(visible_budget.saturating_sub(1)); - let mut active_section: Option = None; - let mut rendered_rows = 0usize; + let rows = self.render_rows(); + let row_start = Self::visible_row_start(&rows, self.selected, visible_budget); - for (slot, idx) in self.filtered.iter().enumerate() { - if slot < scroll { - continue; - } - if rendered_rows >= visible_budget { - break; - } - - let entry = &self.entries[*idx]; - if active_section != Some(entry.section) { - if rendered_rows > 0 { - lines.push(Line::from("")); - rendered_rows += 1; + for row in rows.iter().skip(row_start).take(visible_budget) { + match *row { + HelpRenderRow::Section(section) => { + let count = self + .filtered + .iter() + .filter(|idx| self.entries[**idx].section == section) + .count(); + lines.push(Line::from(Span::styled( + format!(" {} ({})", section.label(self.locale), count), + Style::default() + .fg(palette::DEEPSEEK_BLUE) + .add_modifier(Modifier::BOLD), + ))); } - let count = self - .filtered - .iter() - .filter(|idx| self.entries[**idx].section == entry.section) - .count(); - lines.push(Line::from(Span::styled( - format!(" {} ({})", entry.section.label(self.locale), count), - Style::default() - .fg(palette::DEEPSEEK_BLUE) - .add_modifier(Modifier::BOLD), - ))); - rendered_rows += 1; - active_section = Some(entry.section); - if rendered_rows >= visible_budget { - break; + HelpRenderRow::Entry { slot, entry_idx } => { + let entry = &self.entries[entry_idx]; + let is_selected = slot == self.selected; + let style = if is_selected { + Style::default() + .fg(palette::SELECTION_TEXT) + .bg(palette::DEEPSEEK_BLUE) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette::TEXT_PRIMARY) + }; + let cursor = if is_selected { "▶ " } else { " " }; + let label = truncate_to_width(&entry.label, label_width); + let desc = truncate_to_width(&entry.description, desc_capacity); + let line_text = + format!("{cursor}{label: