Skip to main content

ftui_widgets/
help.rs

1//! Help widget for displaying keybinding lists.
2//!
3//! Renders a styled list of key/description pairs for showing available
4//! keyboard shortcuts in a TUI application.
5//!
6//! # Example
7//!
8//! ```
9//! use ftui_widgets::help::{Help, HelpEntry};
10//!
11//! let help = Help::new()
12//!     .entry("q", "quit")
13//!     .entry("^s", "save")
14//!     .entry("?", "toggle help");
15//!
16//! assert_eq!(help.entries().len(), 3);
17//! ```
18
19use crate::{StatefulWidget, Widget, draw_text_span};
20use ftui_core::geometry::Rect;
21use ftui_render::budget::DegradationLevel;
22use ftui_render::buffer::Buffer;
23use ftui_render::cell::{Cell, PackedRgba};
24use ftui_render::frame::Frame;
25use ftui_style::Style;
26use ftui_style::StyleFlags;
27use ftui_text::wrap::display_width;
28use std::hash::{Hash, Hasher};
29
30/// Category for organizing help entries into logical groups.
31#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
32pub enum HelpCategory {
33    /// General/uncategorized keybinding.
34    #[default]
35    General,
36    /// Navigation keys (arrows, page up/down, home/end, etc.).
37    Navigation,
38    /// Editing actions (cut, copy, paste, undo, redo, etc.).
39    Editing,
40    /// File operations (save, open, close, new, etc.).
41    File,
42    /// View/display controls (zoom, scroll, toggle panels, etc.).
43    View,
44    /// Application-level shortcuts (quit, settings, help, etc.).
45    Global,
46    /// Custom category with a user-defined label.
47    Custom(String),
48}
49
50impl HelpCategory {
51    /// Return a display label for this category.
52    #[must_use]
53    pub fn label(&self) -> &str {
54        match self {
55            Self::General => "General",
56            Self::Navigation => "Navigation",
57            Self::Editing => "Editing",
58            Self::File => "File",
59            Self::View => "View",
60            Self::Global => "Global",
61            Self::Custom(s) => s,
62        }
63    }
64}
65
66/// A single keybinding entry in the help view.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct HelpEntry {
69    /// The key or key combination (e.g. "^C", "↑/k").
70    pub key: String,
71    /// Description of what the key does.
72    pub desc: String,
73    /// Whether this entry is enabled (disabled entries are hidden).
74    pub enabled: bool,
75    /// Category for grouping related entries.
76    pub category: HelpCategory,
77}
78
79impl HelpEntry {
80    /// Create a new enabled help entry.
81    #[must_use]
82    pub fn new(key: impl Into<String>, desc: impl Into<String>) -> Self {
83        Self {
84            key: key.into(),
85            desc: desc.into(),
86            enabled: true,
87            category: HelpCategory::default(),
88        }
89    }
90
91    /// Set whether this entry is enabled.
92    #[must_use]
93    pub fn with_enabled(mut self, enabled: bool) -> Self {
94        self.enabled = enabled;
95        self
96    }
97
98    /// Set the category for this entry.
99    #[must_use]
100    pub fn with_category(mut self, category: HelpCategory) -> Self {
101        self.category = category;
102        self
103    }
104}
105
106/// Display mode for the help widget.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
108pub enum HelpMode {
109    /// Short inline mode: entries separated by a bullet on one line.
110    #[default]
111    Short,
112    /// Full mode: entries stacked vertically with aligned columns.
113    Full,
114}
115
116/// Help widget that renders keybinding entries.
117///
118/// In [`HelpMode::Short`] mode, entries are shown inline separated by a bullet
119/// character, truncated with an ellipsis if they exceed the available width.
120///
121/// In [`HelpMode::Full`] mode, entries are rendered in a vertical list with
122/// keys and descriptions in aligned columns.
123#[derive(Debug, Clone)]
124pub struct Help {
125    entries: Vec<HelpEntry>,
126    mode: HelpMode,
127    /// Separator between entries in short mode.
128    separator: String,
129    /// Ellipsis shown when truncated.
130    ellipsis: String,
131    /// Style for key text.
132    key_style: Style,
133    /// Style for description text.
134    desc_style: Style,
135    /// Style for separator/ellipsis.
136    separator_style: Style,
137}
138
139/// Cached render state for [`Help`], enabling incremental layout reuse and
140/// dirty-rect updates for keybinding hint panels.
141///
142/// # Invariants
143/// - Layout is reused only when entry count and slot widths remain compatible.
144/// - Dirty rects always cover the full prior slot width for changed entries.
145/// - Layout rebuilds on any change that could cause reflow.
146///
147/// # Failure Modes
148/// - If a changed entry exceeds its cached slot width, we rebuild the layout.
149/// - If enabled entry count changes, we rebuild the layout.
150#[derive(Debug, Default)]
151pub struct HelpRenderState {
152    cache: Option<HelpCache>,
153    enabled_indices: Vec<usize>,
154    dirty_indices: Vec<usize>,
155    dirty_rects: Vec<Rect>,
156    stats: HelpCacheStats,
157}
158
159/// Cache hit/miss statistics for [`HelpRenderState`].
160#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
161pub struct HelpCacheStats {
162    pub hits: u64,
163    pub misses: u64,
164    pub dirty_updates: u64,
165    pub layout_rebuilds: u64,
166}
167
168impl HelpRenderState {
169    /// Return cache statistics.
170    #[must_use]
171    pub fn stats(&self) -> HelpCacheStats {
172        self.stats
173    }
174
175    /// Clear recorded dirty rects.
176    pub fn clear_dirty_rects(&mut self) {
177        self.dirty_rects.clear();
178    }
179
180    /// Take dirty rects for logging/inspection.
181    #[must_use]
182    pub fn take_dirty_rects(&mut self) -> Vec<Rect> {
183        std::mem::take(&mut self.dirty_rects)
184    }
185
186    /// Read dirty rects without clearing.
187    #[must_use]
188    pub fn dirty_rects(&self) -> &[Rect] {
189        &self.dirty_rects
190    }
191
192    /// Reset cache stats (useful for perf logging).
193    pub fn reset_stats(&mut self) {
194        self.stats = HelpCacheStats::default();
195    }
196}
197
198#[derive(Debug)]
199struct HelpCache {
200    buffer: Buffer,
201    layout: HelpLayout,
202    key: LayoutKey,
203    entry_hashes: Vec<u64>,
204    enabled_count: usize,
205}
206
207#[derive(Debug, Clone)]
208struct HelpLayout {
209    mode: HelpMode,
210    width: u16,
211    entries: Vec<EntrySlot>,
212    ellipsis: Option<EllipsisSlot>,
213    max_key_width: usize,
214    separator_width: usize,
215}
216
217#[derive(Debug, Clone)]
218struct EntrySlot {
219    x: u16,
220    y: u16,
221    width: u16,
222    key_width: usize,
223}
224
225#[derive(Debug, Clone)]
226struct EllipsisSlot {
227    x: u16,
228    width: u16,
229    prefix_space: bool,
230}
231
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
233struct StyleKey {
234    fg: Option<PackedRgba>,
235    bg: Option<PackedRgba>,
236    attrs: Option<StyleFlags>,
237}
238
239impl From<Style> for StyleKey {
240    fn from(style: Style) -> Self {
241        Self {
242            fg: style.fg,
243            bg: style.bg,
244            attrs: style.attrs,
245        }
246    }
247}
248
249#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
250struct LayoutKey {
251    mode: HelpMode,
252    width: u16,
253    height: u16,
254    separator_hash: u64,
255    ellipsis_hash: u64,
256    key_style: StyleKey,
257    desc_style: StyleKey,
258    separator_style: StyleKey,
259    degradation: DegradationLevel,
260}
261
262impl Default for Help {
263    fn default() -> Self {
264        Self::new()
265    }
266}
267
268impl Help {
269    /// Create a new help widget with no entries.
270    #[must_use]
271    pub fn new() -> Self {
272        Self {
273            entries: Vec::new(),
274            mode: HelpMode::Short,
275            separator: " • ".to_string(),
276            ellipsis: "…".to_string(),
277            key_style: Style::new().bold(),
278            desc_style: Style::default(),
279            separator_style: Style::default(),
280        }
281    }
282
283    /// Add an entry to the help widget.
284    #[must_use]
285    pub fn entry(mut self, key: impl Into<String>, desc: impl Into<String>) -> Self {
286        self.entries.push(HelpEntry::new(key, desc));
287        self
288    }
289
290    /// Add a pre-built entry.
291    #[must_use]
292    pub fn with_entry(mut self, entry: HelpEntry) -> Self {
293        self.entries.push(entry);
294        self
295    }
296
297    /// Set all entries at once.
298    #[must_use]
299    pub fn with_entries(mut self, entries: Vec<HelpEntry>) -> Self {
300        self.entries = entries;
301        self
302    }
303
304    /// Set the display mode.
305    #[must_use]
306    pub fn with_mode(mut self, mode: HelpMode) -> Self {
307        self.mode = mode;
308        self
309    }
310
311    /// Set the separator used between entries in short mode.
312    #[must_use]
313    pub fn with_separator(mut self, sep: impl Into<String>) -> Self {
314        self.separator = sep.into();
315        self
316    }
317
318    /// Set the ellipsis string.
319    #[must_use]
320    pub fn with_ellipsis(mut self, ellipsis: impl Into<String>) -> Self {
321        self.ellipsis = ellipsis.into();
322        self
323    }
324
325    /// Set the style for key text.
326    #[must_use]
327    pub fn with_key_style(mut self, style: Style) -> Self {
328        self.key_style = style;
329        self
330    }
331
332    /// Set the style for description text.
333    #[must_use]
334    pub fn with_desc_style(mut self, style: Style) -> Self {
335        self.desc_style = style;
336        self
337    }
338
339    /// Set the style for separators and ellipsis.
340    #[must_use]
341    pub fn with_separator_style(mut self, style: Style) -> Self {
342        self.separator_style = style;
343        self
344    }
345
346    /// Get the entries.
347    #[must_use]
348    pub fn entries(&self) -> &[HelpEntry] {
349        &self.entries
350    }
351
352    /// Get the current mode.
353    #[must_use]
354    pub fn mode(&self) -> HelpMode {
355        self.mode
356    }
357
358    /// Toggle between short and full mode.
359    pub fn toggle_mode(&mut self) {
360        self.mode = match self.mode {
361            HelpMode::Short => HelpMode::Full,
362            HelpMode::Full => HelpMode::Short,
363        };
364    }
365
366    /// Add an entry mutably.
367    pub fn push_entry(&mut self, entry: HelpEntry) {
368        self.entries.push(entry);
369    }
370
371    /// Collect the enabled entries.
372    fn enabled_entries(&self) -> Vec<&HelpEntry> {
373        self.entries.iter().filter(|e| e.enabled).collect()
374    }
375
376    /// Render short mode: entries inline on one line.
377    fn render_short(&self, area: Rect, frame: &mut Frame) {
378        let entries = self.enabled_entries();
379        if entries.is_empty() || area.width == 0 || area.height == 0 {
380            return;
381        }
382
383        let deg = frame.buffer.degradation;
384        let sep_width = display_width(&self.separator);
385        let ellipsis_width = display_width(&self.ellipsis);
386        let max_x = area.right();
387        let y = area.y;
388        let mut x = area.x;
389
390        for (i, entry) in entries.iter().enumerate() {
391            if entry.key.is_empty() && entry.desc.is_empty() {
392                continue;
393            }
394
395            // Separator before non-first items
396            let sep_w = if i > 0 { sep_width } else { 0 };
397
398            // Calculate item width: key + " " + desc
399            let key_w = display_width(&entry.key);
400            let desc_w = display_width(&entry.desc);
401            let item_w = key_w + 1 + desc_w;
402            let total_item_w = sep_w + item_w;
403
404            // Check if this item fits, accounting for possible ellipsis
405            let space_left = (max_x as usize).saturating_sub(x as usize);
406            if total_item_w > space_left {
407                // Try to fit ellipsis
408                let ell_total = if i > 0 {
409                    1 + ellipsis_width
410                } else {
411                    ellipsis_width
412                };
413                if ell_total <= space_left && deg.apply_styling() {
414                    if i > 0 {
415                        x = draw_text_span(frame, x, y, " ", self.separator_style, max_x);
416                    }
417                    draw_text_span(frame, x, y, &self.ellipsis, self.separator_style, max_x);
418                }
419                break;
420            }
421
422            // Draw separator
423            if i > 0 {
424                if deg.apply_styling() {
425                    x = draw_text_span(frame, x, y, &self.separator, self.separator_style, max_x);
426                } else {
427                    x = draw_text_span(frame, x, y, &self.separator, Style::default(), max_x);
428                }
429            }
430
431            // Draw key
432            if deg.apply_styling() {
433                x = draw_text_span(frame, x, y, &entry.key, self.key_style, max_x);
434                x = draw_text_span(frame, x, y, " ", self.desc_style, max_x);
435                x = draw_text_span(frame, x, y, &entry.desc, self.desc_style, max_x);
436            } else {
437                let text = format!("{} {}", entry.key, entry.desc);
438                x = draw_text_span(frame, x, y, &text, Style::default(), max_x);
439            }
440        }
441    }
442
443    /// Render full mode: entries stacked vertically with aligned columns.
444    fn render_full(&self, area: Rect, frame: &mut Frame) {
445        let entries = self.enabled_entries();
446        if entries.is_empty() || area.width == 0 || area.height == 0 {
447            return;
448        }
449
450        let deg = frame.buffer.degradation;
451
452        // Find max key width for alignment
453        let max_key_w = entries
454            .iter()
455            .filter(|e| !e.key.is_empty() || !e.desc.is_empty())
456            .map(|e| display_width(&e.key))
457            .max()
458            .unwrap_or(0);
459
460        let max_x = area.right();
461        let mut row: u16 = 0;
462
463        for entry in &entries {
464            if entry.key.is_empty() && entry.desc.is_empty() {
465                continue;
466            }
467            if row >= area.height {
468                break;
469            }
470
471            let y = area.y.saturating_add(row);
472            let mut x = area.x;
473
474            if deg.apply_styling() {
475                // Draw key, right-padded to max_key_w
476                let key_w = display_width(&entry.key);
477                x = draw_text_span(frame, x, y, &entry.key, self.key_style, max_x);
478                // Pad to alignment
479                let pad = max_key_w.saturating_sub(key_w);
480                for _ in 0..pad {
481                    x = draw_text_span(frame, x, y, " ", Style::default(), max_x);
482                }
483                // Space between key and desc
484                x = draw_text_span(frame, x, y, "  ", Style::default(), max_x);
485                // Draw description
486                draw_text_span(frame, x, y, &entry.desc, self.desc_style, max_x);
487            } else {
488                let text = format!("{:>width$}  {}", entry.key, entry.desc, width = max_key_w);
489                draw_text_span(frame, x, y, &text, Style::default(), max_x);
490            }
491
492            row += 1;
493        }
494    }
495
496    fn entry_hash(entry: &HelpEntry) -> u64 {
497        let mut hasher = std::collections::hash_map::DefaultHasher::new();
498        entry.key.hash(&mut hasher);
499        entry.desc.hash(&mut hasher);
500        entry.enabled.hash(&mut hasher);
501        entry.category.hash(&mut hasher);
502        hasher.finish()
503    }
504
505    fn hash_str(value: &str) -> u64 {
506        let mut hasher = std::collections::hash_map::DefaultHasher::new();
507        value.hash(&mut hasher);
508        hasher.finish()
509    }
510
511    fn layout_key(&self, area: Rect, degradation: DegradationLevel) -> LayoutKey {
512        LayoutKey {
513            mode: self.mode,
514            width: area.width,
515            height: area.height,
516            separator_hash: Self::hash_str(&self.separator),
517            ellipsis_hash: Self::hash_str(&self.ellipsis),
518            key_style: StyleKey::from(self.key_style),
519            desc_style: StyleKey::from(self.desc_style),
520            separator_style: StyleKey::from(self.separator_style),
521            degradation,
522        }
523    }
524
525    fn build_layout(&self, area: Rect) -> HelpLayout {
526        match self.mode {
527            HelpMode::Short => self.build_short_layout(area),
528            HelpMode::Full => self.build_full_layout(area),
529        }
530    }
531
532    fn build_short_layout(&self, area: Rect) -> HelpLayout {
533        let mut entries = Vec::new();
534        let mut ellipsis = None;
535        let sep_width = display_width(&self.separator);
536        let ellipsis_width = display_width(&self.ellipsis);
537        let max_x = area.width;
538        let mut x: u16 = 0;
539        let mut first = true;
540
541        for entry in self
542            .entries
543            .iter()
544            .filter(|e| e.enabled && (!e.key.is_empty() || !e.desc.is_empty()))
545        {
546            let key_width = display_width(&entry.key);
547            let desc_width = display_width(&entry.desc);
548            let item_width = key_width + 1 + desc_width;
549            let total_width = if first {
550                item_width
551            } else {
552                sep_width + item_width
553            };
554            let space_left = (max_x as usize).saturating_sub(x as usize);
555
556            if total_width > space_left {
557                let ell_total = if first {
558                    ellipsis_width
559                } else {
560                    1 + ellipsis_width
561                };
562                if ell_total <= space_left {
563                    ellipsis = Some(EllipsisSlot {
564                        x,
565                        width: ell_total as u16,
566                        prefix_space: !first,
567                    });
568                }
569                break;
570            }
571
572            entries.push(EntrySlot {
573                x,
574                y: 0,
575                width: total_width as u16,
576                key_width,
577            });
578            x = x.saturating_add(total_width as u16);
579            first = false;
580        }
581
582        HelpLayout {
583            mode: HelpMode::Short,
584            width: area.width,
585            entries,
586            ellipsis,
587            max_key_width: 0,
588            separator_width: sep_width,
589        }
590    }
591
592    fn build_full_layout(&self, area: Rect) -> HelpLayout {
593        let mut max_key_width = 0usize;
594        for entry in self
595            .entries
596            .iter()
597            .filter(|e| e.enabled && (!e.key.is_empty() || !e.desc.is_empty()))
598        {
599            let key_width = display_width(&entry.key);
600            max_key_width = max_key_width.max(key_width);
601        }
602
603        let mut entries = Vec::new();
604        let mut row: u16 = 0;
605        for entry in self
606            .entries
607            .iter()
608            .filter(|e| e.enabled && (!e.key.is_empty() || !e.desc.is_empty()))
609        {
610            if row >= area.height {
611                break;
612            }
613            let key_width = display_width(&entry.key);
614            let desc_width = display_width(&entry.desc);
615            let entry_width = max_key_width.saturating_add(2).saturating_add(desc_width);
616            let slot_width = entry_width.min(area.width as usize) as u16;
617            entries.push(EntrySlot {
618                x: 0,
619                y: row,
620                width: slot_width,
621                key_width,
622            });
623            row = row.saturating_add(1);
624        }
625
626        HelpLayout {
627            mode: HelpMode::Full,
628            width: area.width,
629            entries,
630            ellipsis: None,
631            max_key_width,
632            separator_width: 0,
633        }
634    }
635
636    fn render_cached(&self, area: Rect, frame: &mut Frame, layout: &HelpLayout) {
637        match layout.mode {
638            HelpMode::Short => self.render_short_cached(area, frame, layout),
639            HelpMode::Full => self.render_full_cached(area, frame, layout),
640        }
641    }
642
643    fn render_short_cached(&self, area: Rect, frame: &mut Frame, layout: &HelpLayout) {
644        if layout.entries.is_empty() || area.width == 0 || area.height == 0 {
645            return;
646        }
647
648        let deg = frame.buffer.degradation;
649        let max_x = area.right();
650        let mut enabled_iter = self
651            .entries
652            .iter()
653            .filter(|e| e.enabled && (!e.key.is_empty() || !e.desc.is_empty()));
654
655        for (idx, slot) in layout.entries.iter().enumerate() {
656            let Some(entry) = enabled_iter.next() else {
657                break;
658            };
659            let mut x = area.x.saturating_add(slot.x);
660            let y = area.y.saturating_add(slot.y);
661
662            if idx > 0 {
663                let sep_style = if deg.apply_styling() {
664                    self.separator_style
665                } else {
666                    Style::default()
667                };
668                x = draw_text_span(frame, x, y, &self.separator, sep_style, max_x);
669            }
670
671            let key_style = if deg.apply_styling() {
672                self.key_style
673            } else {
674                Style::default()
675            };
676            let desc_style = if deg.apply_styling() {
677                self.desc_style
678            } else {
679                Style::default()
680            };
681
682            x = draw_text_span(frame, x, y, &entry.key, key_style, max_x);
683            x = draw_text_span(frame, x, y, " ", desc_style, max_x);
684            draw_text_span(frame, x, y, &entry.desc, desc_style, max_x);
685        }
686
687        if let Some(ellipsis) = &layout.ellipsis {
688            let y = area.y.saturating_add(0);
689            let mut x = area.x.saturating_add(ellipsis.x);
690            let ellipsis_style = if deg.apply_styling() {
691                self.separator_style
692            } else {
693                Style::default()
694            };
695            if ellipsis.prefix_space {
696                x = draw_text_span(frame, x, y, " ", ellipsis_style, max_x);
697            }
698            draw_text_span(frame, x, y, &self.ellipsis, ellipsis_style, max_x);
699        }
700    }
701
702    fn render_full_cached(&self, area: Rect, frame: &mut Frame, layout: &HelpLayout) {
703        if layout.entries.is_empty() || area.width == 0 || area.height == 0 {
704            return;
705        }
706
707        let deg = frame.buffer.degradation;
708        let max_x = area.right();
709
710        let mut enabled_iter = self
711            .entries
712            .iter()
713            .filter(|e| e.enabled && (!e.key.is_empty() || !e.desc.is_empty()));
714
715        for slot in layout.entries.iter() {
716            let Some(entry) = enabled_iter.next() else {
717                break;
718            };
719
720            let y = area.y.saturating_add(slot.y);
721            let mut x = area.x.saturating_add(slot.x);
722
723            let key_style = if deg.apply_styling() {
724                self.key_style
725            } else {
726                Style::default()
727            };
728            let desc_style = if deg.apply_styling() {
729                self.desc_style
730            } else {
731                Style::default()
732            };
733
734            x = draw_text_span(frame, x, y, &entry.key, key_style, max_x);
735            let pad = layout.max_key_width.saturating_sub(slot.key_width);
736            for _ in 0..pad {
737                x = draw_text_span(frame, x, y, " ", Style::default(), max_x);
738            }
739            x = draw_text_span(frame, x, y, "  ", Style::default(), max_x);
740            draw_text_span(frame, x, y, &entry.desc, desc_style, max_x);
741        }
742    }
743
744    fn render_short_entry(&self, slot: &EntrySlot, entry: &HelpEntry, frame: &mut Frame) {
745        let deg = frame.buffer.degradation;
746        let max_x = slot.x.saturating_add(slot.width);
747
748        let rect = Rect::new(slot.x, slot.y, slot.width, 1);
749        frame.buffer.fill(rect, Cell::default());
750
751        let mut x = slot.x;
752        if slot.x > 0 {
753            let sep_style = if deg.apply_styling() {
754                self.separator_style
755            } else {
756                Style::default()
757            };
758            x = draw_text_span(frame, x, slot.y, &self.separator, sep_style, max_x);
759        }
760
761        let key_style = if deg.apply_styling() {
762            self.key_style
763        } else {
764            Style::default()
765        };
766        let desc_style = if deg.apply_styling() {
767            self.desc_style
768        } else {
769            Style::default()
770        };
771
772        x = draw_text_span(frame, x, slot.y, &entry.key, key_style, max_x);
773        x = draw_text_span(frame, x, slot.y, " ", desc_style, max_x);
774        draw_text_span(frame, x, slot.y, &entry.desc, desc_style, max_x);
775    }
776
777    fn render_full_entry(
778        &self,
779        slot: &EntrySlot,
780        entry: &HelpEntry,
781        layout: &HelpLayout,
782        frame: &mut Frame,
783    ) {
784        let deg = frame.buffer.degradation;
785        let max_x = slot.x.saturating_add(slot.width);
786
787        let rect = Rect::new(slot.x, slot.y, slot.width, 1);
788        frame.buffer.fill(rect, Cell::default());
789
790        let mut x = slot.x;
791        let key_style = if deg.apply_styling() {
792            self.key_style
793        } else {
794            Style::default()
795        };
796        let desc_style = if deg.apply_styling() {
797            self.desc_style
798        } else {
799            Style::default()
800        };
801
802        x = draw_text_span(frame, x, slot.y, &entry.key, key_style, max_x);
803        let pad = layout.max_key_width.saturating_sub(slot.key_width);
804        for _ in 0..pad {
805            x = draw_text_span(frame, x, slot.y, " ", Style::default(), max_x);
806        }
807        x = draw_text_span(frame, x, slot.y, "  ", Style::default(), max_x);
808        draw_text_span(frame, x, slot.y, &entry.desc, desc_style, max_x);
809    }
810}
811
812impl Widget for Help {
813    fn render(&self, area: Rect, frame: &mut Frame) {
814        match self.mode {
815            HelpMode::Short => self.render_short(area, frame),
816            HelpMode::Full => self.render_full(area, frame),
817        }
818    }
819
820    fn is_essential(&self) -> bool {
821        false
822    }
823}
824
825impl StatefulWidget for Help {
826    type State = HelpRenderState;
827
828    fn render(&self, area: Rect, frame: &mut Frame, state: &mut HelpRenderState) {
829        if area.is_empty() || area.width == 0 || area.height == 0 {
830            state.cache = None;
831            return;
832        }
833
834        state.dirty_rects.clear();
835        state.dirty_indices.clear();
836
837        let layout_key = self.layout_key(area, frame.buffer.degradation);
838        let enabled_count = collect_enabled_indices(&self.entries, &mut state.enabled_indices);
839
840        let cache_miss = state
841            .cache
842            .as_ref()
843            .is_none_or(|cache| cache.key != layout_key);
844
845        if cache_miss {
846            rebuild_cache(self, area, frame, state, layout_key, enabled_count);
847            blit_cache(state.cache.as_ref(), area, frame);
848            return;
849        }
850
851        let cache = state
852            .cache
853            .as_mut()
854            .expect("cache present after miss check");
855        if enabled_count != cache.enabled_count {
856            rebuild_cache(self, area, frame, state, layout_key, enabled_count);
857            blit_cache(state.cache.as_ref(), area, frame);
858            return;
859        }
860
861        let mut layout_changed = false;
862        let visible_count = cache.layout.entries.len();
863
864        for (pos, entry_idx) in state.enabled_indices.iter().enumerate() {
865            let entry = &self.entries[*entry_idx];
866            let hash = Help::entry_hash(entry);
867
868            if pos >= cache.entry_hashes.len() {
869                layout_changed = true;
870                break;
871            }
872
873            if hash != cache.entry_hashes[pos] {
874                if pos >= visible_count || !entry_fits_slot(entry, pos, &cache.layout) {
875                    layout_changed = true;
876                    break;
877                }
878                cache.entry_hashes[pos] = hash;
879                state.dirty_indices.push(pos);
880            }
881        }
882
883        if layout_changed {
884            rebuild_cache(self, area, frame, state, layout_key, enabled_count);
885            blit_cache(state.cache.as_ref(), area, frame);
886            return;
887        }
888
889        if state.dirty_indices.is_empty() {
890            state.stats.hits += 1;
891            blit_cache(state.cache.as_ref(), area, frame);
892            return;
893        }
894
895        // Partial update: only changed entries are redrawn into the cached buffer.
896        state.stats.dirty_updates += 1;
897
898        let cache = state
899            .cache
900            .as_mut()
901            .expect("cache present for dirty update");
902        let mut cache_buffer = std::mem::take(&mut cache.buffer);
903        cache_buffer.degradation = frame.buffer.degradation;
904        {
905            let mut cache_frame = Frame {
906                buffer: cache_buffer,
907                pool: frame.pool,
908                links: None,
909                hit_grid: None,
910                widget_budget: frame.widget_budget.clone(),
911                widget_signals: Vec::new(),
912                cursor_position: None,
913                cursor_visible: true,
914                degradation: frame.buffer.degradation,
915            };
916
917            for idx in &state.dirty_indices {
918                if let Some(entry_idx) = state.enabled_indices.get(*idx)
919                    && let Some(slot) = cache.layout.entries.get(*idx)
920                {
921                    let entry = &self.entries[*entry_idx];
922                    match cache.layout.mode {
923                        HelpMode::Short => self.render_short_entry(slot, entry, &mut cache_frame),
924                        HelpMode::Full => {
925                            self.render_full_entry(slot, entry, &cache.layout, &mut cache_frame)
926                        }
927                    }
928                    state
929                        .dirty_rects
930                        .push(Rect::new(slot.x, slot.y, slot.width, 1));
931                }
932            }
933
934            cache_buffer = cache_frame.buffer;
935        }
936        cache.buffer = cache_buffer;
937
938        blit_cache(state.cache.as_ref(), area, frame);
939    }
940}
941
942fn collect_enabled_indices(entries: &[HelpEntry], out: &mut Vec<usize>) -> usize {
943    out.clear();
944    for (idx, entry) in entries.iter().enumerate() {
945        if entry.enabled && (!entry.key.is_empty() || !entry.desc.is_empty()) {
946            out.push(idx);
947        }
948    }
949    out.len()
950}
951
952fn entry_fits_slot(entry: &HelpEntry, index: usize, layout: &HelpLayout) -> bool {
953    match layout.mode {
954        HelpMode::Short => {
955            let entry_width = display_width(&entry.key) + 1 + display_width(&entry.desc);
956            let slot = match layout.entries.get(index) {
957                Some(slot) => slot,
958                None => return false,
959            };
960            let sep_width = layout.separator_width;
961            let max_width = if slot.x == 0 {
962                slot.width as usize
963            } else {
964                slot.width.saturating_sub(sep_width as u16) as usize
965            };
966            entry_width <= max_width
967        }
968        HelpMode::Full => {
969            let key_width = display_width(&entry.key);
970            let desc_width = display_width(&entry.desc);
971            let entry_width = layout
972                .max_key_width
973                .saturating_add(2)
974                .saturating_add(desc_width);
975            let slot = match layout.entries.get(index) {
976                Some(slot) => slot,
977                None => return false,
978            };
979            if slot.width == layout.width {
980                key_width <= layout.max_key_width
981            } else {
982                key_width <= layout.max_key_width && entry_width <= slot.width as usize
983            }
984        }
985    }
986}
987
988fn rebuild_cache(
989    help: &Help,
990    area: Rect,
991    frame: &mut Frame,
992    state: &mut HelpRenderState,
993    layout_key: LayoutKey,
994    enabled_count: usize,
995) {
996    state.stats.misses += 1;
997    state.stats.layout_rebuilds += 1;
998
999    let layout_area = Rect::new(0, 0, area.width, area.height);
1000    let layout = help.build_layout(layout_area);
1001
1002    let mut buffer = Buffer::new(area.width, area.height);
1003    buffer.degradation = frame.buffer.degradation;
1004    {
1005        let mut cache_frame = Frame {
1006            buffer,
1007            pool: frame.pool,
1008            links: None,
1009            hit_grid: None,
1010            widget_budget: frame.widget_budget.clone(),
1011            widget_signals: Vec::new(),
1012            cursor_position: None,
1013            cursor_visible: true,
1014            degradation: frame.buffer.degradation,
1015        };
1016        help.render_cached(layout_area, &mut cache_frame, &layout);
1017        buffer = cache_frame.buffer;
1018    }
1019
1020    let mut entry_hashes = Vec::with_capacity(state.enabled_indices.len());
1021    for idx in &state.enabled_indices {
1022        entry_hashes.push(Help::entry_hash(&help.entries[*idx]));
1023    }
1024
1025    state.cache = Some(HelpCache {
1026        buffer,
1027        layout,
1028        key: layout_key,
1029        entry_hashes,
1030        enabled_count,
1031    });
1032}
1033
1034fn blit_cache(cache: Option<&HelpCache>, area: Rect, frame: &mut Frame) {
1035    let Some(cache) = cache else {
1036        return;
1037    };
1038
1039    for slot in &cache.layout.entries {
1040        let src = Rect::new(slot.x, slot.y, slot.width, 1);
1041        frame
1042            .buffer
1043            .copy_from(&cache.buffer, src, area.x + slot.x, area.y + slot.y);
1044    }
1045
1046    if let Some(ellipsis) = &cache.layout.ellipsis {
1047        let src = Rect::new(ellipsis.x, 0, ellipsis.width, 1);
1048        frame
1049            .buffer
1050            .copy_from(&cache.buffer, src, area.x + ellipsis.x, area.y);
1051    }
1052}
1053
1054/// Format for displaying key labels in hints.
1055#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
1056pub enum KeyFormat {
1057    /// Plain key display: `q quit`
1058    #[default]
1059    Plain,
1060    /// Bracketed key display: `[q] quit`
1061    Bracketed,
1062}
1063
1064/// A keybinding hints widget with category grouping and context-aware filtering.
1065///
1066/// Supports two entry scopes:
1067/// - **Global**: shortcuts always visible regardless of context.
1068/// - **Contextual**: shortcuts shown only when [`show_context`](Self::with_show_context)
1069///   is enabled (typically when a particular widget has focus).
1070///
1071/// In [`HelpMode::Full`] mode with categories enabled, entries are grouped
1072/// under category headers. In [`HelpMode::Short`] mode, entries are rendered
1073/// inline.
1074///
1075/// # Example
1076///
1077/// ```
1078/// use ftui_widgets::help::{KeybindingHints, HelpCategory, KeyFormat};
1079///
1080/// let hints = KeybindingHints::new()
1081///     .with_key_format(KeyFormat::Bracketed)
1082///     .global_entry("q", "quit")
1083///     .global_entry_categorized("Tab", "next", HelpCategory::Navigation)
1084///     .contextual_entry_categorized("^s", "save", HelpCategory::File);
1085///
1086/// assert_eq!(hints.global_entries().len(), 2);
1087/// assert_eq!(hints.contextual_entries().len(), 1);
1088/// ```
1089#[derive(Debug, Clone)]
1090pub struct KeybindingHints {
1091    global_entries: Vec<HelpEntry>,
1092    contextual_entries: Vec<HelpEntry>,
1093    key_format: KeyFormat,
1094    mode: HelpMode,
1095    key_style: Style,
1096    desc_style: Style,
1097    separator_style: Style,
1098    category_style: Style,
1099    separator: String,
1100    ellipsis: String,
1101    show_categories: bool,
1102    show_context: bool,
1103}
1104
1105impl Default for KeybindingHints {
1106    fn default() -> Self {
1107        Self::new()
1108    }
1109}
1110
1111impl KeybindingHints {
1112    /// Create a new hints widget with no entries.
1113    #[must_use]
1114    pub fn new() -> Self {
1115        Self {
1116            global_entries: Vec::new(),
1117            contextual_entries: Vec::new(),
1118            key_format: KeyFormat::default(),
1119            mode: HelpMode::Short,
1120            key_style: Style::new().bold(),
1121            desc_style: Style::default(),
1122            separator_style: Style::default(),
1123            category_style: Style::new().bold().underline(),
1124            separator: " • ".to_string(),
1125            ellipsis: "…".to_string(),
1126            show_categories: true,
1127            show_context: false,
1128        }
1129    }
1130
1131    /// Add a global entry (always visible).
1132    #[must_use]
1133    pub fn global_entry(mut self, key: impl Into<String>, desc: impl Into<String>) -> Self {
1134        self.global_entries
1135            .push(HelpEntry::new(key, desc).with_category(HelpCategory::Global));
1136        self
1137    }
1138
1139    /// Add a global entry with a specific category.
1140    #[must_use]
1141    pub fn global_entry_categorized(
1142        mut self,
1143        key: impl Into<String>,
1144        desc: impl Into<String>,
1145        category: HelpCategory,
1146    ) -> Self {
1147        self.global_entries
1148            .push(HelpEntry::new(key, desc).with_category(category));
1149        self
1150    }
1151
1152    /// Add a contextual entry (shown when context is active).
1153    #[must_use]
1154    pub fn contextual_entry(mut self, key: impl Into<String>, desc: impl Into<String>) -> Self {
1155        self.contextual_entries.push(HelpEntry::new(key, desc));
1156        self
1157    }
1158
1159    /// Add a contextual entry with a specific category.
1160    #[must_use]
1161    pub fn contextual_entry_categorized(
1162        mut self,
1163        key: impl Into<String>,
1164        desc: impl Into<String>,
1165        category: HelpCategory,
1166    ) -> Self {
1167        self.contextual_entries
1168            .push(HelpEntry::new(key, desc).with_category(category));
1169        self
1170    }
1171
1172    /// Add a pre-built global entry.
1173    #[must_use]
1174    pub fn with_global_entry(mut self, entry: HelpEntry) -> Self {
1175        self.global_entries.push(entry);
1176        self
1177    }
1178
1179    /// Add a pre-built contextual entry.
1180    #[must_use]
1181    pub fn with_contextual_entry(mut self, entry: HelpEntry) -> Self {
1182        self.contextual_entries.push(entry);
1183        self
1184    }
1185
1186    /// Set the key display format.
1187    #[must_use]
1188    pub fn with_key_format(mut self, format: KeyFormat) -> Self {
1189        self.key_format = format;
1190        self
1191    }
1192
1193    /// Set the display mode.
1194    #[must_use]
1195    pub fn with_mode(mut self, mode: HelpMode) -> Self {
1196        self.mode = mode;
1197        self
1198    }
1199
1200    /// Set whether contextual entries are shown.
1201    #[must_use]
1202    pub fn with_show_context(mut self, show: bool) -> Self {
1203        self.show_context = show;
1204        self
1205    }
1206
1207    /// Set whether category headers are shown in full mode.
1208    #[must_use]
1209    pub fn with_show_categories(mut self, show: bool) -> Self {
1210        self.show_categories = show;
1211        self
1212    }
1213
1214    /// Set the style for key text.
1215    #[must_use]
1216    pub fn with_key_style(mut self, style: Style) -> Self {
1217        self.key_style = style;
1218        self
1219    }
1220
1221    /// Set the style for description text.
1222    #[must_use]
1223    pub fn with_desc_style(mut self, style: Style) -> Self {
1224        self.desc_style = style;
1225        self
1226    }
1227
1228    /// Set the style for separators.
1229    #[must_use]
1230    pub fn with_separator_style(mut self, style: Style) -> Self {
1231        self.separator_style = style;
1232        self
1233    }
1234
1235    /// Set the style for category headers.
1236    #[must_use]
1237    pub fn with_category_style(mut self, style: Style) -> Self {
1238        self.category_style = style;
1239        self
1240    }
1241
1242    /// Set the separator string for short mode.
1243    #[must_use]
1244    pub fn with_separator(mut self, sep: impl Into<String>) -> Self {
1245        self.separator = sep.into();
1246        self
1247    }
1248
1249    /// Get the global entries.
1250    #[must_use]
1251    pub fn global_entries(&self) -> &[HelpEntry] {
1252        &self.global_entries
1253    }
1254
1255    /// Get the contextual entries.
1256    #[must_use]
1257    pub fn contextual_entries(&self) -> &[HelpEntry] {
1258        &self.contextual_entries
1259    }
1260
1261    /// Get the current mode.
1262    #[must_use]
1263    pub fn mode(&self) -> HelpMode {
1264        self.mode
1265    }
1266
1267    /// Get the key format.
1268    #[must_use]
1269    pub fn key_format(&self) -> KeyFormat {
1270        self.key_format
1271    }
1272
1273    /// Toggle between short and full mode.
1274    pub fn toggle_mode(&mut self) {
1275        self.mode = match self.mode {
1276            HelpMode::Short => HelpMode::Full,
1277            HelpMode::Full => HelpMode::Short,
1278        };
1279    }
1280
1281    /// Set whether contextual entries are shown (mutable).
1282    pub fn set_show_context(&mut self, show: bool) {
1283        self.show_context = show;
1284    }
1285
1286    /// Format a key string according to the current key format.
1287    fn format_key(&self, key: &str) -> String {
1288        match self.key_format {
1289            KeyFormat::Plain => key.to_string(),
1290            KeyFormat::Bracketed => format!("[{key}]"),
1291        }
1292    }
1293
1294    /// Collect visible entries, applying scope filter and key formatting.
1295    #[must_use]
1296    pub fn visible_entries(&self) -> Vec<HelpEntry> {
1297        let mut entries = Vec::new();
1298        for e in &self.global_entries {
1299            if e.enabled {
1300                entries.push(HelpEntry {
1301                    key: self.format_key(&e.key),
1302                    desc: e.desc.clone(),
1303                    enabled: true,
1304                    category: e.category.clone(),
1305                });
1306            }
1307        }
1308        if self.show_context {
1309            for e in &self.contextual_entries {
1310                if e.enabled {
1311                    entries.push(HelpEntry {
1312                        key: self.format_key(&e.key),
1313                        desc: e.desc.clone(),
1314                        enabled: true,
1315                        category: e.category.clone(),
1316                    });
1317                }
1318            }
1319        }
1320        entries
1321    }
1322
1323    /// Group entries by category, preserving insertion order within each group.
1324    fn grouped_entries(entries: &[HelpEntry]) -> Vec<(&HelpCategory, Vec<&HelpEntry>)> {
1325        let mut groups: Vec<(&HelpCategory, Vec<&HelpEntry>)> = Vec::new();
1326        for entry in entries {
1327            if let Some(group) = groups.iter_mut().find(|(cat, _)| **cat == entry.category) {
1328                group.1.push(entry);
1329            } else {
1330                groups.push((&entry.category, vec![entry]));
1331            }
1332        }
1333        groups
1334    }
1335
1336    /// Render full mode with category headers.
1337    fn render_full_grouped(&self, entries: &[HelpEntry], area: Rect, frame: &mut Frame) {
1338        let groups = Self::grouped_entries(entries);
1339        let deg = frame.buffer.degradation;
1340        let max_x = area.right();
1341        let mut y = area.y;
1342
1343        // Find max key width across all entries for alignment.
1344        let max_key_w = entries
1345            .iter()
1346            .map(|e| display_width(&e.key))
1347            .max()
1348            .unwrap_or(0);
1349
1350        for (i, (cat, group_entries)) in groups.iter().enumerate() {
1351            if y >= area.bottom() {
1352                break;
1353            }
1354
1355            // Category header
1356            let cat_style = if deg.apply_styling() {
1357                self.category_style
1358            } else {
1359                Style::default()
1360            };
1361            draw_text_span(frame, area.x, y, cat.label(), cat_style, max_x);
1362            y += 1;
1363
1364            // Entries in this category
1365            for entry in group_entries {
1366                if y >= area.bottom() {
1367                    break;
1368                }
1369
1370                let key_style = if deg.apply_styling() {
1371                    self.key_style
1372                } else {
1373                    Style::default()
1374                };
1375                let desc_style = if deg.apply_styling() {
1376                    self.desc_style
1377                } else {
1378                    Style::default()
1379                };
1380
1381                let mut x = area.x;
1382                x = draw_text_span(frame, x, y, &entry.key, key_style, max_x);
1383                let pad = max_key_w.saturating_sub(display_width(&entry.key));
1384                for _ in 0..pad {
1385                    x = draw_text_span(frame, x, y, " ", Style::default(), max_x);
1386                }
1387                x = draw_text_span(frame, x, y, "  ", Style::default(), max_x);
1388                draw_text_span(frame, x, y, &entry.desc, desc_style, max_x);
1389                y += 1;
1390            }
1391
1392            // Blank line between groups (except after last)
1393            if i + 1 < groups.len() {
1394                y += 1;
1395            }
1396        }
1397    }
1398}
1399
1400impl Widget for KeybindingHints {
1401    fn render(&self, area: Rect, frame: &mut Frame) {
1402        let entries = self.visible_entries();
1403        if entries.is_empty() || area.is_empty() {
1404            return;
1405        }
1406
1407        match self.mode {
1408            HelpMode::Short => {
1409                // In short mode, render all entries inline using Help widget.
1410                let help = Help::new()
1411                    .with_mode(HelpMode::Short)
1412                    .with_key_style(self.key_style)
1413                    .with_desc_style(self.desc_style)
1414                    .with_separator_style(self.separator_style)
1415                    .with_separator(self.separator.clone())
1416                    .with_ellipsis(self.ellipsis.clone())
1417                    .with_entries(entries);
1418                Widget::render(&help, area, frame);
1419            }
1420            HelpMode::Full => {
1421                if self.show_categories {
1422                    self.render_full_grouped(&entries, area, frame);
1423                } else {
1424                    let help = Help::new()
1425                        .with_mode(HelpMode::Full)
1426                        .with_key_style(self.key_style)
1427                        .with_desc_style(self.desc_style)
1428                        .with_entries(entries);
1429                    Widget::render(&help, area, frame);
1430                }
1431            }
1432        }
1433    }
1434
1435    fn is_essential(&self) -> bool {
1436        false
1437    }
1438}
1439
1440#[cfg(test)]
1441mod tests {
1442    use super::*;
1443    use ftui_render::frame::Frame;
1444    use ftui_render::grapheme_pool::GraphemePool;
1445    use proptest::prelude::*;
1446    use proptest::string::string_regex;
1447    use std::time::Instant;
1448
1449    #[test]
1450    fn new_help_is_empty() {
1451        let help = Help::new();
1452        assert!(help.entries().is_empty());
1453        assert_eq!(help.mode(), HelpMode::Short);
1454    }
1455
1456    #[test]
1457    fn entry_builder() {
1458        let help = Help::new().entry("q", "quit").entry("^s", "save");
1459        assert_eq!(help.entries().len(), 2);
1460        assert_eq!(help.entries()[0].key, "q");
1461        assert_eq!(help.entries()[0].desc, "quit");
1462    }
1463
1464    #[test]
1465    fn with_entries_replaces() {
1466        let help = Help::new()
1467            .entry("old", "old")
1468            .with_entries(vec![HelpEntry::new("new", "new")]);
1469        assert_eq!(help.entries().len(), 1);
1470        assert_eq!(help.entries()[0].key, "new");
1471    }
1472
1473    #[test]
1474    fn disabled_entries_hidden() {
1475        let help = Help::new()
1476            .with_entry(HelpEntry::new("a", "shown"))
1477            .with_entry(HelpEntry::new("b", "hidden").with_enabled(false))
1478            .with_entry(HelpEntry::new("c", "also shown"));
1479        assert_eq!(help.enabled_entries().len(), 2);
1480    }
1481
1482    #[test]
1483    fn toggle_mode() {
1484        let mut help = Help::new();
1485        assert_eq!(help.mode(), HelpMode::Short);
1486        help.toggle_mode();
1487        assert_eq!(help.mode(), HelpMode::Full);
1488        help.toggle_mode();
1489        assert_eq!(help.mode(), HelpMode::Short);
1490    }
1491
1492    #[test]
1493    fn push_entry() {
1494        let mut help = Help::new();
1495        help.push_entry(HelpEntry::new("x", "action"));
1496        assert_eq!(help.entries().len(), 1);
1497    }
1498
1499    #[test]
1500    fn render_short_basic() {
1501        let help = Help::new().entry("q", "quit").entry("^s", "save");
1502
1503        let mut pool = GraphemePool::new();
1504        let mut frame = Frame::new(40, 1, &mut pool);
1505        let area = Rect::new(0, 0, 40, 1);
1506        Widget::render(&help, area, &mut frame);
1507
1508        // Check that key text appears in buffer
1509        let cell_q = frame.buffer.get(0, 0).unwrap();
1510        assert_eq!(cell_q.content.as_char(), Some('q'));
1511    }
1512
1513    #[test]
1514    fn render_short_truncation() {
1515        let help = Help::new()
1516            .entry("q", "quit")
1517            .entry("^s", "save")
1518            .entry("^x", "something very long that should not fit");
1519
1520        let mut pool = GraphemePool::new();
1521        let mut frame = Frame::new(20, 1, &mut pool);
1522        let area = Rect::new(0, 0, 20, 1);
1523        Widget::render(&help, area, &mut frame);
1524
1525        // First entry should be present
1526        let cell = frame.buffer.get(0, 0).unwrap();
1527        assert_eq!(cell.content.as_char(), Some('q'));
1528    }
1529
1530    #[test]
1531    fn render_short_empty_entries() {
1532        let help = Help::new();
1533
1534        let mut pool = GraphemePool::new();
1535        let mut frame = Frame::new(20, 1, &mut pool);
1536        let area = Rect::new(0, 0, 20, 1);
1537        Widget::render(&help, area, &mut frame);
1538
1539        // Buffer should remain default (empty cell)
1540        let cell = frame.buffer.get(0, 0).unwrap();
1541        assert!(cell.content.is_empty() || cell.content.as_char() == Some(' '));
1542    }
1543
1544    #[test]
1545    fn render_full_basic() {
1546        let help = Help::new()
1547            .with_mode(HelpMode::Full)
1548            .entry("q", "quit")
1549            .entry("^s", "save file");
1550
1551        let mut pool = GraphemePool::new();
1552        let mut frame = Frame::new(30, 5, &mut pool);
1553        let area = Rect::new(0, 0, 30, 5);
1554        Widget::render(&help, area, &mut frame);
1555
1556        // First row should have "q" key
1557        let cell = frame.buffer.get(0, 0).unwrap();
1558        assert!(cell.content.as_char() == Some(' ') || cell.content.as_char() == Some('q'));
1559        // Second row should have "^s" key (right-padded: " ^s")
1560        let cell_row2 = frame.buffer.get(0, 1).unwrap();
1561        assert!(
1562            cell_row2.content.as_char() == Some('^') || cell_row2.content.as_char() == Some(' ')
1563        );
1564    }
1565
1566    #[test]
1567    fn render_full_respects_height() {
1568        let help = Help::new()
1569            .with_mode(HelpMode::Full)
1570            .entry("a", "first")
1571            .entry("b", "second")
1572            .entry("c", "third");
1573
1574        let mut pool = GraphemePool::new();
1575        // Only 2 rows available
1576        let mut frame = Frame::new(30, 2, &mut pool);
1577        let area = Rect::new(0, 0, 30, 2);
1578        Widget::render(&help, area, &mut frame);
1579
1580        // Only first two entries should render (height=2)
1581        // No crash, no panic
1582    }
1583
1584    #[test]
1585    fn help_entry_equality() {
1586        let a = HelpEntry::new("q", "quit");
1587        let b = HelpEntry::new("q", "quit");
1588        let c = HelpEntry::new("x", "exit");
1589        assert_eq!(a, b);
1590        assert_ne!(a, c);
1591    }
1592
1593    #[test]
1594    fn help_entry_disabled() {
1595        let entry = HelpEntry::new("q", "quit").with_enabled(false);
1596        assert!(!entry.enabled);
1597    }
1598
1599    #[test]
1600    fn with_separator() {
1601        let help = Help::new().with_separator(" | ");
1602        assert_eq!(help.separator, " | ");
1603    }
1604
1605    #[test]
1606    fn with_ellipsis() {
1607        let help = Help::new().with_ellipsis("...");
1608        assert_eq!(help.ellipsis, "...");
1609    }
1610
1611    #[test]
1612    fn render_zero_area() {
1613        let help = Help::new().entry("q", "quit");
1614
1615        let mut pool = GraphemePool::new();
1616        let mut frame = Frame::new(20, 1, &mut pool);
1617        let area = Rect::new(0, 0, 0, 0);
1618        Widget::render(&help, area, &mut frame); // Should not panic
1619    }
1620
1621    #[test]
1622    fn is_not_essential() {
1623        let help = Help::new();
1624        assert!(!help.is_essential());
1625    }
1626
1627    #[test]
1628    fn render_full_alignment() {
1629        // Verify key column alignment in full mode
1630        let help = Help::new()
1631            .with_mode(HelpMode::Full)
1632            .entry("q", "quit")
1633            .entry("ctrl+s", "save");
1634
1635        let mut pool = GraphemePool::new();
1636        let mut frame = Frame::new(30, 3, &mut pool);
1637        let area = Rect::new(0, 0, 30, 3);
1638        Widget::render(&help, area, &mut frame);
1639
1640        // "q" is 1 char, "ctrl+s" is 6 chars, max_key_w = 6
1641        // Row 0: "q      quit" (q + 5 spaces + 2 spaces + quit)
1642        // Row 1: "ctrl+s  save"
1643        // Check that descriptions start at the same column
1644        // Key col = 6, gap = 2, desc starts at col 8
1645    }
1646
1647    #[test]
1648    fn default_impl() {
1649        let help = Help::default();
1650        assert!(help.entries().is_empty());
1651    }
1652
1653    #[test]
1654    fn cache_hit_same_hints() {
1655        let help = Help::new().entry("q", "quit").entry("^s", "save");
1656        let mut state = HelpRenderState::default();
1657        let mut pool = GraphemePool::new();
1658        let mut frame = Frame::new(40, 1, &mut pool);
1659        let area = Rect::new(0, 0, 40, 1);
1660
1661        StatefulWidget::render(&help, area, &mut frame, &mut state);
1662        let stats_after_first = state.stats();
1663        StatefulWidget::render(&help, area, &mut frame, &mut state);
1664        let stats_after_second = state.stats();
1665
1666        assert!(
1667            stats_after_second.hits > stats_after_first.hits,
1668            "Second render should be a cache hit"
1669        );
1670        assert!(state.dirty_rects().is_empty(), "No dirty rects on hit");
1671    }
1672
1673    #[test]
1674    fn dirty_rect_only_changes() {
1675        let mut help = Help::new()
1676            .with_mode(HelpMode::Full)
1677            .entry("q", "quit")
1678            .entry("w", "write")
1679            .entry("e", "edit");
1680
1681        let mut state = HelpRenderState::default();
1682        let mut pool = GraphemePool::new();
1683        let mut frame = Frame::new(40, 3, &mut pool);
1684        let area = Rect::new(0, 0, 40, 3);
1685
1686        StatefulWidget::render(&help, area, &mut frame, &mut state);
1687
1688        help.entries[1].desc.clear();
1689        help.entries[1].desc.push_str("save");
1690
1691        StatefulWidget::render(&help, area, &mut frame, &mut state);
1692        let dirty = state.take_dirty_rects();
1693
1694        assert_eq!(dirty.len(), 1, "Only one row should be dirty");
1695        assert_eq!(dirty[0].y, 1, "Second entry row should be dirty");
1696    }
1697
1698    proptest! {
1699        #[test]
1700        fn prop_cache_hits_on_stable_entries(entries in prop::collection::vec(
1701            (string_regex("[a-z]{1,6}").unwrap(), string_regex("[a-z]{1,10}").unwrap()),
1702            1..6
1703        )) {
1704            let mut help = Help::new();
1705            for (key, desc) in entries {
1706                help = help.entry(key, desc);
1707            }
1708            let mut state = HelpRenderState::default();
1709            let mut pool = GraphemePool::new();
1710            let mut frame = Frame::new(80, 1, &mut pool);
1711            let area = Rect::new(0, 0, 80, 1);
1712
1713            StatefulWidget::render(&help, area, &mut frame, &mut state);
1714            let stats_after_first = state.stats();
1715            StatefulWidget::render(&help, area, &mut frame, &mut state);
1716            let stats_after_second = state.stats();
1717
1718            prop_assert!(stats_after_second.hits > stats_after_first.hits);
1719            prop_assert!(state.dirty_rects().is_empty());
1720        }
1721    }
1722
1723    #[test]
1724    fn perf_micro_hint_update() {
1725        let mut help = Help::new()
1726            .with_mode(HelpMode::Short)
1727            .entry("^T", "Theme")
1728            .entry("^C", "Quit")
1729            .entry("?", "Help")
1730            .entry("F12", "Debug");
1731
1732        let mut state = HelpRenderState::default();
1733        let mut pool = GraphemePool::new();
1734        let mut frame = Frame::new(120, 1, &mut pool);
1735        let area = Rect::new(0, 0, 120, 1);
1736
1737        StatefulWidget::render(&help, area, &mut frame, &mut state);
1738
1739        let iterations = 200u32;
1740        let mut times_us = Vec::with_capacity(iterations as usize);
1741        for i in 0..iterations {
1742            let label = if i % 2 == 0 { "Close" } else { "Open" };
1743            help.entries[1].desc.clear();
1744            help.entries[1].desc.push_str(label);
1745
1746            let start = Instant::now();
1747            StatefulWidget::render(&help, area, &mut frame, &mut state);
1748            let elapsed = start.elapsed();
1749            times_us.push(elapsed.as_micros() as u64);
1750        }
1751
1752        times_us.sort();
1753        let len = times_us.len();
1754        let p50 = times_us[len / 2];
1755        let p95 = times_us[((len as f64 * 0.95) as usize).min(len.saturating_sub(1))];
1756        let p99 = times_us[((len as f64 * 0.99) as usize).min(len.saturating_sub(1))];
1757        let updates_per_sec = 1_000_000u64.checked_div(p50).unwrap_or(0);
1758
1759        eprintln!(
1760            "{{\"ts\":\"2026-02-03T00:00:00Z\",\"case\":\"help_hint_update\",\"iterations\":{},\"p50_us\":{},\"p95_us\":{},\"p99_us\":{},\"updates_per_sec\":{},\"hits\":{},\"misses\":{},\"dirty_updates\":{}}}",
1761            iterations,
1762            p50,
1763            p95,
1764            p99,
1765            updates_per_sec,
1766            state.stats().hits,
1767            state.stats().misses,
1768            state.stats().dirty_updates
1769        );
1770
1771        // Budget: keep p95 under 2ms in CI (500 updates/sec).
1772        assert!(p95 <= 2000, "p95 too slow: {p95}us");
1773    }
1774
1775    // ── HelpCategory tests ─────────────────────────────────────────
1776
1777    #[test]
1778    fn help_category_default_is_general() {
1779        assert_eq!(HelpCategory::default(), HelpCategory::General);
1780    }
1781
1782    #[test]
1783    fn help_category_labels() {
1784        assert_eq!(HelpCategory::General.label(), "General");
1785        assert_eq!(HelpCategory::Navigation.label(), "Navigation");
1786        assert_eq!(HelpCategory::Editing.label(), "Editing");
1787        assert_eq!(HelpCategory::File.label(), "File");
1788        assert_eq!(HelpCategory::View.label(), "View");
1789        assert_eq!(HelpCategory::Global.label(), "Global");
1790        assert_eq!(
1791            HelpCategory::Custom("My Section".into()).label(),
1792            "My Section"
1793        );
1794    }
1795
1796    #[test]
1797    fn help_entry_with_category() {
1798        let entry = HelpEntry::new("q", "quit").with_category(HelpCategory::Navigation);
1799        assert_eq!(entry.category, HelpCategory::Navigation);
1800    }
1801
1802    #[test]
1803    fn help_entry_default_category_is_general() {
1804        let entry = HelpEntry::new("q", "quit");
1805        assert_eq!(entry.category, HelpCategory::General);
1806    }
1807
1808    #[test]
1809    fn category_changes_entry_hash() {
1810        let a = HelpEntry::new("q", "quit");
1811        let b = HelpEntry::new("q", "quit").with_category(HelpCategory::Navigation);
1812        assert_ne!(Help::entry_hash(&a), Help::entry_hash(&b));
1813    }
1814
1815    // ── KeyFormat tests ────────────────────────────────────────────
1816
1817    #[test]
1818    fn key_format_default_is_plain() {
1819        assert_eq!(KeyFormat::default(), KeyFormat::Plain);
1820    }
1821
1822    // ── KeybindingHints tests ──────────────────────────────────────
1823
1824    #[test]
1825    fn keybinding_hints_new_is_empty() {
1826        let hints = KeybindingHints::new();
1827        assert!(hints.global_entries().is_empty());
1828        assert!(hints.contextual_entries().is_empty());
1829        assert_eq!(hints.mode(), HelpMode::Short);
1830        assert_eq!(hints.key_format(), KeyFormat::Plain);
1831    }
1832
1833    #[test]
1834    fn keybinding_hints_default() {
1835        let hints = KeybindingHints::default();
1836        assert!(hints.global_entries().is_empty());
1837    }
1838
1839    #[test]
1840    fn keybinding_hints_global_entry() {
1841        let hints = KeybindingHints::new()
1842            .global_entry("q", "quit")
1843            .global_entry("^s", "save");
1844        assert_eq!(hints.global_entries().len(), 2);
1845        assert_eq!(hints.global_entries()[0].key, "q");
1846        assert_eq!(hints.global_entries()[0].category, HelpCategory::Global);
1847    }
1848
1849    #[test]
1850    fn keybinding_hints_categorized_entries() {
1851        let hints = KeybindingHints::new()
1852            .global_entry_categorized("Tab", "next", HelpCategory::Navigation)
1853            .global_entry_categorized("q", "quit", HelpCategory::Global);
1854        assert_eq!(hints.global_entries()[0].category, HelpCategory::Navigation);
1855        assert_eq!(hints.global_entries()[1].category, HelpCategory::Global);
1856    }
1857
1858    #[test]
1859    fn keybinding_hints_contextual_entry() {
1860        let hints = KeybindingHints::new()
1861            .contextual_entry("^s", "save")
1862            .contextual_entry_categorized("^f", "find", HelpCategory::Editing);
1863        assert_eq!(hints.contextual_entries().len(), 2);
1864        assert_eq!(
1865            hints.contextual_entries()[0].category,
1866            HelpCategory::General
1867        );
1868        assert_eq!(
1869            hints.contextual_entries()[1].category,
1870            HelpCategory::Editing
1871        );
1872    }
1873
1874    #[test]
1875    fn keybinding_hints_with_prebuilt_entries() {
1876        let global = HelpEntry::new("q", "quit").with_category(HelpCategory::Global);
1877        let ctx = HelpEntry::new("^s", "save").with_category(HelpCategory::File);
1878        let hints = KeybindingHints::new()
1879            .with_global_entry(global)
1880            .with_contextual_entry(ctx);
1881        assert_eq!(hints.global_entries().len(), 1);
1882        assert_eq!(hints.contextual_entries().len(), 1);
1883    }
1884
1885    #[test]
1886    fn keybinding_hints_toggle_mode() {
1887        let mut hints = KeybindingHints::new();
1888        assert_eq!(hints.mode(), HelpMode::Short);
1889        hints.toggle_mode();
1890        assert_eq!(hints.mode(), HelpMode::Full);
1891        hints.toggle_mode();
1892        assert_eq!(hints.mode(), HelpMode::Short);
1893    }
1894
1895    #[test]
1896    fn keybinding_hints_set_show_context() {
1897        let mut hints = KeybindingHints::new()
1898            .global_entry("q", "quit")
1899            .contextual_entry("^s", "save");
1900
1901        // Context off: only global visible
1902        let visible = hints.visible_entries();
1903        assert_eq!(visible.len(), 1);
1904
1905        // Context on: both visible
1906        hints.set_show_context(true);
1907        let visible = hints.visible_entries();
1908        assert_eq!(visible.len(), 2);
1909    }
1910
1911    #[test]
1912    fn keybinding_hints_bracketed_format() {
1913        let hints = KeybindingHints::new()
1914            .with_key_format(KeyFormat::Bracketed)
1915            .global_entry("q", "quit");
1916        let visible = hints.visible_entries();
1917        assert_eq!(visible[0].key, "[q]");
1918    }
1919
1920    #[test]
1921    fn keybinding_hints_plain_format() {
1922        let hints = KeybindingHints::new()
1923            .with_key_format(KeyFormat::Plain)
1924            .global_entry("q", "quit");
1925        let visible = hints.visible_entries();
1926        assert_eq!(visible[0].key, "q");
1927    }
1928
1929    #[test]
1930    fn keybinding_hints_disabled_entries_hidden() {
1931        let hints = KeybindingHints::new()
1932            .with_global_entry(HelpEntry::new("a", "shown"))
1933            .with_global_entry(HelpEntry::new("b", "hidden").with_enabled(false));
1934        let visible = hints.visible_entries();
1935        assert_eq!(visible.len(), 1);
1936        assert_eq!(visible[0].key, "a");
1937    }
1938
1939    #[test]
1940    fn keybinding_hints_grouped_entries() {
1941        let entries = vec![
1942            HelpEntry::new("Tab", "next").with_category(HelpCategory::Navigation),
1943            HelpEntry::new("q", "quit").with_category(HelpCategory::Global),
1944            HelpEntry::new("S-Tab", "prev").with_category(HelpCategory::Navigation),
1945        ];
1946        let groups = KeybindingHints::grouped_entries(&entries);
1947        assert_eq!(groups.len(), 2);
1948        assert_eq!(*groups[0].0, HelpCategory::Navigation);
1949        assert_eq!(groups[0].1.len(), 2);
1950        assert_eq!(*groups[1].0, HelpCategory::Global);
1951        assert_eq!(groups[1].1.len(), 1);
1952    }
1953
1954    #[test]
1955    fn keybinding_hints_render_short() {
1956        let hints = KeybindingHints::new()
1957            .global_entry("q", "quit")
1958            .global_entry("^s", "save");
1959
1960        let mut pool = GraphemePool::new();
1961        let mut frame = Frame::new(40, 1, &mut pool);
1962        let area = Rect::new(0, 0, 40, 1);
1963        Widget::render(&hints, area, &mut frame);
1964
1965        // First char should be 'q' (plain format)
1966        let cell = frame.buffer.get(0, 0).unwrap();
1967        assert_eq!(cell.content.as_char(), Some('q'));
1968    }
1969
1970    #[test]
1971    fn keybinding_hints_render_short_bracketed() {
1972        let hints = KeybindingHints::new()
1973            .with_key_format(KeyFormat::Bracketed)
1974            .global_entry("q", "quit");
1975
1976        let mut pool = GraphemePool::new();
1977        let mut frame = Frame::new(40, 1, &mut pool);
1978        let area = Rect::new(0, 0, 40, 1);
1979        Widget::render(&hints, area, &mut frame);
1980
1981        // First char should be '[' (bracketed format)
1982        let cell = frame.buffer.get(0, 0).unwrap();
1983        assert_eq!(cell.content.as_char(), Some('['));
1984    }
1985
1986    #[test]
1987    fn keybinding_hints_render_full_grouped() {
1988        let hints = KeybindingHints::new()
1989            .with_mode(HelpMode::Full)
1990            .with_show_categories(true)
1991            .global_entry_categorized("Tab", "next", HelpCategory::Navigation)
1992            .global_entry_categorized("q", "quit", HelpCategory::Global);
1993
1994        let mut pool = GraphemePool::new();
1995        let mut frame = Frame::new(40, 10, &mut pool);
1996        let area = Rect::new(0, 0, 40, 10);
1997        Widget::render(&hints, area, &mut frame);
1998
1999        // Row 0 should contain category header "Navigation"
2000        let mut row0 = String::new();
2001        for x in 0..40u16 {
2002            if let Some(cell) = frame.buffer.get(x, 0)
2003                && let Some(ch) = cell.content.as_char()
2004            {
2005                row0.push(ch);
2006            }
2007        }
2008        assert!(
2009            row0.contains("Navigation"),
2010            "First row should be Navigation header: {row0}"
2011        );
2012    }
2013
2014    #[test]
2015    fn keybinding_hints_render_full_no_categories() {
2016        let hints = KeybindingHints::new()
2017            .with_mode(HelpMode::Full)
2018            .with_show_categories(false)
2019            .global_entry("q", "quit")
2020            .global_entry("^s", "save");
2021
2022        let mut pool = GraphemePool::new();
2023        let mut frame = Frame::new(40, 5, &mut pool);
2024        let area = Rect::new(0, 0, 40, 5);
2025        // Should not panic
2026        Widget::render(&hints, area, &mut frame);
2027    }
2028
2029    #[test]
2030    fn keybinding_hints_render_empty() {
2031        let hints = KeybindingHints::new();
2032
2033        let mut pool = GraphemePool::new();
2034        let mut frame = Frame::new(20, 1, &mut pool);
2035        let area = Rect::new(0, 0, 20, 1);
2036        // Should not panic
2037        Widget::render(&hints, area, &mut frame);
2038    }
2039
2040    #[test]
2041    fn keybinding_hints_render_zero_area() {
2042        let hints = KeybindingHints::new().global_entry("q", "quit");
2043
2044        let mut pool = GraphemePool::new();
2045        let mut frame = Frame::new(20, 1, &mut pool);
2046        let area = Rect::new(0, 0, 0, 0);
2047        // Should not panic
2048        Widget::render(&hints, area, &mut frame);
2049    }
2050
2051    #[test]
2052    fn keybinding_hints_is_not_essential() {
2053        let hints = KeybindingHints::new();
2054        assert!(!hints.is_essential());
2055    }
2056
2057    // ── Property tests for KeybindingHints ──────────────────────────
2058
2059    proptest! {
2060        #[test]
2061        fn prop_visible_entries_count(
2062            n_global in 0..5usize,
2063            n_ctx in 0..5usize,
2064            show_ctx in proptest::bool::ANY,
2065        ) {
2066            let mut hints = KeybindingHints::new().with_show_context(show_ctx);
2067            for i in 0..n_global {
2068                hints = hints.global_entry(format!("g{i}"), format!("global {i}"));
2069            }
2070            for i in 0..n_ctx {
2071                hints = hints.contextual_entry(format!("c{i}"), format!("ctx {i}"));
2072            }
2073            let visible = hints.visible_entries();
2074            let expected = if show_ctx { n_global + n_ctx } else { n_global };
2075            prop_assert_eq!(visible.len(), expected);
2076        }
2077
2078        #[test]
2079        fn prop_bracketed_keys_wrapped(
2080            keys in prop::collection::vec(string_regex("[a-z]{1,4}").unwrap(), 1..5),
2081        ) {
2082            let mut hints = KeybindingHints::new().with_key_format(KeyFormat::Bracketed);
2083            for key in &keys {
2084                hints = hints.global_entry(key.clone(), "action");
2085            }
2086            let visible = hints.visible_entries();
2087            for entry in &visible {
2088                prop_assert!(entry.key.starts_with('['), "Key should start with [: {}", entry.key);
2089                prop_assert!(entry.key.ends_with(']'), "Key should end with ]: {}", entry.key);
2090            }
2091        }
2092
2093        #[test]
2094        fn prop_grouped_preserves_count(
2095            entries in prop::collection::vec(
2096                (string_regex("[a-z]{1,4}").unwrap(), 0..3u8),
2097                1..8
2098            ),
2099        ) {
2100            let help_entries: Vec<HelpEntry> = entries.into_iter().map(|(key, cat_idx)| {
2101                let cat = match cat_idx {
2102                    0 => HelpCategory::Navigation,
2103                    1 => HelpCategory::Editing,
2104                    _ => HelpCategory::Global,
2105                };
2106                HelpEntry::new(key, "action").with_category(cat)
2107            }).collect();
2108
2109            let total = help_entries.len();
2110            let groups = KeybindingHints::grouped_entries(&help_entries);
2111            let grouped_total: usize = groups.iter().map(|(_, v)| v.len()).sum();
2112            prop_assert_eq!(total, grouped_total, "Grouping should preserve total entry count");
2113        }
2114
2115        #[test]
2116        fn prop_render_no_panic(
2117            n_global in 0..5usize,
2118            n_ctx in 0..5usize,
2119            width in 1..80u16,
2120            height in 1..20u16,
2121            show_ctx in proptest::bool::ANY,
2122            use_full in proptest::bool::ANY,
2123            use_brackets in proptest::bool::ANY,
2124            show_cats in proptest::bool::ANY,
2125        ) {
2126            let mode = if use_full { HelpMode::Full } else { HelpMode::Short };
2127            let fmt = if use_brackets { KeyFormat::Bracketed } else { KeyFormat::Plain };
2128            let mut hints = KeybindingHints::new()
2129                .with_mode(mode)
2130                .with_key_format(fmt)
2131                .with_show_context(show_ctx)
2132                .with_show_categories(show_cats);
2133
2134            for i in 0..n_global {
2135                hints = hints.global_entry(format!("g{i}"), format!("global action {i}"));
2136            }
2137            for i in 0..n_ctx {
2138                hints = hints.contextual_entry(format!("c{i}"), format!("ctx action {i}"));
2139            }
2140
2141            let mut pool = GraphemePool::new();
2142            let mut frame = Frame::new(width, height, &mut pool);
2143            let area = Rect::new(0, 0, width, height);
2144            Widget::render(&hints, area, &mut frame);
2145            // No panic = pass
2146        }
2147    }
2148}