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                arena: None,
916            };
917
918            for idx in &state.dirty_indices {
919                if let Some(entry_idx) = state.enabled_indices.get(*idx)
920                    && let Some(slot) = cache.layout.entries.get(*idx)
921                {
922                    let entry = &self.entries[*entry_idx];
923                    match cache.layout.mode {
924                        HelpMode::Short => self.render_short_entry(slot, entry, &mut cache_frame),
925                        HelpMode::Full => {
926                            self.render_full_entry(slot, entry, &cache.layout, &mut cache_frame)
927                        }
928                    }
929                    state
930                        .dirty_rects
931                        .push(Rect::new(slot.x, slot.y, slot.width, 1));
932                }
933            }
934
935            cache_buffer = cache_frame.buffer;
936        }
937        cache.buffer = cache_buffer;
938
939        blit_cache(state.cache.as_ref(), area, frame);
940    }
941}
942
943fn collect_enabled_indices(entries: &[HelpEntry], out: &mut Vec<usize>) -> usize {
944    out.clear();
945    for (idx, entry) in entries.iter().enumerate() {
946        if entry.enabled && (!entry.key.is_empty() || !entry.desc.is_empty()) {
947            out.push(idx);
948        }
949    }
950    out.len()
951}
952
953fn entry_fits_slot(entry: &HelpEntry, index: usize, layout: &HelpLayout) -> bool {
954    match layout.mode {
955        HelpMode::Short => {
956            let entry_width = display_width(&entry.key) + 1 + display_width(&entry.desc);
957            let slot = match layout.entries.get(index) {
958                Some(slot) => slot,
959                None => return false,
960            };
961            let sep_width = layout.separator_width;
962            let max_width = if slot.x == 0 {
963                slot.width as usize
964            } else {
965                slot.width.saturating_sub(sep_width as u16) as usize
966            };
967            entry_width <= max_width
968        }
969        HelpMode::Full => {
970            let key_width = display_width(&entry.key);
971            let desc_width = display_width(&entry.desc);
972            let entry_width = layout
973                .max_key_width
974                .saturating_add(2)
975                .saturating_add(desc_width);
976            let slot = match layout.entries.get(index) {
977                Some(slot) => slot,
978                None => return false,
979            };
980            if slot.width == layout.width {
981                key_width <= layout.max_key_width
982            } else {
983                key_width <= layout.max_key_width && entry_width <= slot.width as usize
984            }
985        }
986    }
987}
988
989fn rebuild_cache(
990    help: &Help,
991    area: Rect,
992    frame: &mut Frame,
993    state: &mut HelpRenderState,
994    layout_key: LayoutKey,
995    enabled_count: usize,
996) {
997    state.stats.misses += 1;
998    state.stats.layout_rebuilds += 1;
999
1000    let layout_area = Rect::new(0, 0, area.width, area.height);
1001    let layout = help.build_layout(layout_area);
1002
1003    let mut buffer = Buffer::new(area.width, area.height);
1004    buffer.degradation = frame.buffer.degradation;
1005    {
1006        let mut cache_frame = Frame {
1007            buffer,
1008            pool: frame.pool,
1009            links: None,
1010            hit_grid: None,
1011            widget_budget: frame.widget_budget.clone(),
1012            widget_signals: Vec::new(),
1013            cursor_position: None,
1014            cursor_visible: true,
1015            degradation: frame.buffer.degradation,
1016            arena: None,
1017        };
1018        help.render_cached(layout_area, &mut cache_frame, &layout);
1019        buffer = cache_frame.buffer;
1020    }
1021
1022    let mut entry_hashes = Vec::with_capacity(state.enabled_indices.len());
1023    for idx in &state.enabled_indices {
1024        entry_hashes.push(Help::entry_hash(&help.entries[*idx]));
1025    }
1026
1027    state.cache = Some(HelpCache {
1028        buffer,
1029        layout,
1030        key: layout_key,
1031        entry_hashes,
1032        enabled_count,
1033    });
1034}
1035
1036fn blit_cache(cache: Option<&HelpCache>, area: Rect, frame: &mut Frame) {
1037    let Some(cache) = cache else {
1038        return;
1039    };
1040
1041    for slot in &cache.layout.entries {
1042        let src = Rect::new(slot.x, slot.y, slot.width, 1);
1043        frame
1044            .buffer
1045            .copy_from(&cache.buffer, src, area.x + slot.x, area.y + slot.y);
1046    }
1047
1048    if let Some(ellipsis) = &cache.layout.ellipsis {
1049        let src = Rect::new(ellipsis.x, 0, ellipsis.width, 1);
1050        frame
1051            .buffer
1052            .copy_from(&cache.buffer, src, area.x + ellipsis.x, area.y);
1053    }
1054}
1055
1056/// Format for displaying key labels in hints.
1057#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
1058pub enum KeyFormat {
1059    /// Plain key display: `q quit`
1060    #[default]
1061    Plain,
1062    /// Bracketed key display: `[q] quit`
1063    Bracketed,
1064}
1065
1066/// A keybinding hints widget with category grouping and context-aware filtering.
1067///
1068/// Supports two entry scopes:
1069/// - **Global**: shortcuts always visible regardless of context.
1070/// - **Contextual**: shortcuts shown only when [`show_context`](Self::with_show_context)
1071///   is enabled (typically when a particular widget has focus).
1072///
1073/// In [`HelpMode::Full`] mode with categories enabled, entries are grouped
1074/// under category headers. In [`HelpMode::Short`] mode, entries are rendered
1075/// inline.
1076///
1077/// # Example
1078///
1079/// ```
1080/// use ftui_widgets::help::{KeybindingHints, HelpCategory, KeyFormat};
1081///
1082/// let hints = KeybindingHints::new()
1083///     .with_key_format(KeyFormat::Bracketed)
1084///     .global_entry("q", "quit")
1085///     .global_entry_categorized("Tab", "next", HelpCategory::Navigation)
1086///     .contextual_entry_categorized("^s", "save", HelpCategory::File);
1087///
1088/// assert_eq!(hints.global_entries().len(), 2);
1089/// assert_eq!(hints.contextual_entries().len(), 1);
1090/// ```
1091#[derive(Debug, Clone)]
1092pub struct KeybindingHints {
1093    global_entries: Vec<HelpEntry>,
1094    contextual_entries: Vec<HelpEntry>,
1095    key_format: KeyFormat,
1096    mode: HelpMode,
1097    key_style: Style,
1098    desc_style: Style,
1099    separator_style: Style,
1100    category_style: Style,
1101    separator: String,
1102    ellipsis: String,
1103    show_categories: bool,
1104    show_context: bool,
1105}
1106
1107impl Default for KeybindingHints {
1108    fn default() -> Self {
1109        Self::new()
1110    }
1111}
1112
1113impl KeybindingHints {
1114    /// Create a new hints widget with no entries.
1115    #[must_use]
1116    pub fn new() -> Self {
1117        Self {
1118            global_entries: Vec::new(),
1119            contextual_entries: Vec::new(),
1120            key_format: KeyFormat::default(),
1121            mode: HelpMode::Short,
1122            key_style: Style::new().bold(),
1123            desc_style: Style::default(),
1124            separator_style: Style::default(),
1125            category_style: Style::new().bold().underline(),
1126            separator: " • ".to_string(),
1127            ellipsis: "…".to_string(),
1128            show_categories: true,
1129            show_context: false,
1130        }
1131    }
1132
1133    /// Add a global entry (always visible).
1134    #[must_use]
1135    pub fn global_entry(mut self, key: impl Into<String>, desc: impl Into<String>) -> Self {
1136        self.global_entries
1137            .push(HelpEntry::new(key, desc).with_category(HelpCategory::Global));
1138        self
1139    }
1140
1141    /// Add a global entry with a specific category.
1142    #[must_use]
1143    pub fn global_entry_categorized(
1144        mut self,
1145        key: impl Into<String>,
1146        desc: impl Into<String>,
1147        category: HelpCategory,
1148    ) -> Self {
1149        self.global_entries
1150            .push(HelpEntry::new(key, desc).with_category(category));
1151        self
1152    }
1153
1154    /// Add a contextual entry (shown when context is active).
1155    #[must_use]
1156    pub fn contextual_entry(mut self, key: impl Into<String>, desc: impl Into<String>) -> Self {
1157        self.contextual_entries.push(HelpEntry::new(key, desc));
1158        self
1159    }
1160
1161    /// Add a contextual entry with a specific category.
1162    #[must_use]
1163    pub fn contextual_entry_categorized(
1164        mut self,
1165        key: impl Into<String>,
1166        desc: impl Into<String>,
1167        category: HelpCategory,
1168    ) -> Self {
1169        self.contextual_entries
1170            .push(HelpEntry::new(key, desc).with_category(category));
1171        self
1172    }
1173
1174    /// Add a pre-built global entry.
1175    #[must_use]
1176    pub fn with_global_entry(mut self, entry: HelpEntry) -> Self {
1177        self.global_entries.push(entry);
1178        self
1179    }
1180
1181    /// Add a pre-built contextual entry.
1182    #[must_use]
1183    pub fn with_contextual_entry(mut self, entry: HelpEntry) -> Self {
1184        self.contextual_entries.push(entry);
1185        self
1186    }
1187
1188    /// Set the key display format.
1189    #[must_use]
1190    pub fn with_key_format(mut self, format: KeyFormat) -> Self {
1191        self.key_format = format;
1192        self
1193    }
1194
1195    /// Set the display mode.
1196    #[must_use]
1197    pub fn with_mode(mut self, mode: HelpMode) -> Self {
1198        self.mode = mode;
1199        self
1200    }
1201
1202    /// Set whether contextual entries are shown.
1203    #[must_use]
1204    pub fn with_show_context(mut self, show: bool) -> Self {
1205        self.show_context = show;
1206        self
1207    }
1208
1209    /// Set whether category headers are shown in full mode.
1210    #[must_use]
1211    pub fn with_show_categories(mut self, show: bool) -> Self {
1212        self.show_categories = show;
1213        self
1214    }
1215
1216    /// Set the style for key text.
1217    #[must_use]
1218    pub fn with_key_style(mut self, style: Style) -> Self {
1219        self.key_style = style;
1220        self
1221    }
1222
1223    /// Set the style for description text.
1224    #[must_use]
1225    pub fn with_desc_style(mut self, style: Style) -> Self {
1226        self.desc_style = style;
1227        self
1228    }
1229
1230    /// Set the style for separators.
1231    #[must_use]
1232    pub fn with_separator_style(mut self, style: Style) -> Self {
1233        self.separator_style = style;
1234        self
1235    }
1236
1237    /// Set the style for category headers.
1238    #[must_use]
1239    pub fn with_category_style(mut self, style: Style) -> Self {
1240        self.category_style = style;
1241        self
1242    }
1243
1244    /// Set the separator string for short mode.
1245    #[must_use]
1246    pub fn with_separator(mut self, sep: impl Into<String>) -> Self {
1247        self.separator = sep.into();
1248        self
1249    }
1250
1251    /// Get the global entries.
1252    #[must_use]
1253    pub fn global_entries(&self) -> &[HelpEntry] {
1254        &self.global_entries
1255    }
1256
1257    /// Get the contextual entries.
1258    #[must_use]
1259    pub fn contextual_entries(&self) -> &[HelpEntry] {
1260        &self.contextual_entries
1261    }
1262
1263    /// Get the current mode.
1264    #[must_use]
1265    pub fn mode(&self) -> HelpMode {
1266        self.mode
1267    }
1268
1269    /// Get the key format.
1270    #[must_use]
1271    pub fn key_format(&self) -> KeyFormat {
1272        self.key_format
1273    }
1274
1275    /// Toggle between short and full mode.
1276    pub fn toggle_mode(&mut self) {
1277        self.mode = match self.mode {
1278            HelpMode::Short => HelpMode::Full,
1279            HelpMode::Full => HelpMode::Short,
1280        };
1281    }
1282
1283    /// Set whether contextual entries are shown (mutable).
1284    pub fn set_show_context(&mut self, show: bool) {
1285        self.show_context = show;
1286    }
1287
1288    /// Format a key string according to the current key format.
1289    fn format_key(&self, key: &str) -> String {
1290        match self.key_format {
1291            KeyFormat::Plain => key.to_string(),
1292            KeyFormat::Bracketed => format!("[{key}]"),
1293        }
1294    }
1295
1296    /// Collect visible entries, applying scope filter and key formatting.
1297    #[must_use]
1298    pub fn visible_entries(&self) -> Vec<HelpEntry> {
1299        let mut entries = Vec::new();
1300        for e in &self.global_entries {
1301            if e.enabled {
1302                entries.push(HelpEntry {
1303                    key: self.format_key(&e.key),
1304                    desc: e.desc.clone(),
1305                    enabled: true,
1306                    category: e.category.clone(),
1307                });
1308            }
1309        }
1310        if self.show_context {
1311            for e in &self.contextual_entries {
1312                if e.enabled {
1313                    entries.push(HelpEntry {
1314                        key: self.format_key(&e.key),
1315                        desc: e.desc.clone(),
1316                        enabled: true,
1317                        category: e.category.clone(),
1318                    });
1319                }
1320            }
1321        }
1322        entries
1323    }
1324
1325    /// Group entries by category, preserving insertion order within each group.
1326    fn grouped_entries(entries: &[HelpEntry]) -> Vec<(&HelpCategory, Vec<&HelpEntry>)> {
1327        let mut groups: Vec<(&HelpCategory, Vec<&HelpEntry>)> = Vec::new();
1328        for entry in entries {
1329            if let Some(group) = groups.iter_mut().find(|(cat, _)| **cat == entry.category) {
1330                group.1.push(entry);
1331            } else {
1332                groups.push((&entry.category, vec![entry]));
1333            }
1334        }
1335        groups
1336    }
1337
1338    /// Render full mode with category headers.
1339    fn render_full_grouped(&self, entries: &[HelpEntry], area: Rect, frame: &mut Frame) {
1340        let groups = Self::grouped_entries(entries);
1341        let deg = frame.buffer.degradation;
1342        let max_x = area.right();
1343        let mut y = area.y;
1344
1345        // Find max key width across all entries for alignment.
1346        let max_key_w = entries
1347            .iter()
1348            .map(|e| display_width(&e.key))
1349            .max()
1350            .unwrap_or(0);
1351
1352        for (i, (cat, group_entries)) in groups.iter().enumerate() {
1353            if y >= area.bottom() {
1354                break;
1355            }
1356
1357            // Category header
1358            let cat_style = if deg.apply_styling() {
1359                self.category_style
1360            } else {
1361                Style::default()
1362            };
1363            draw_text_span(frame, area.x, y, cat.label(), cat_style, max_x);
1364            y += 1;
1365
1366            // Entries in this category
1367            for entry in group_entries {
1368                if y >= area.bottom() {
1369                    break;
1370                }
1371
1372                let key_style = if deg.apply_styling() {
1373                    self.key_style
1374                } else {
1375                    Style::default()
1376                };
1377                let desc_style = if deg.apply_styling() {
1378                    self.desc_style
1379                } else {
1380                    Style::default()
1381                };
1382
1383                let mut x = area.x;
1384                x = draw_text_span(frame, x, y, &entry.key, key_style, max_x);
1385                let pad = max_key_w.saturating_sub(display_width(&entry.key));
1386                for _ in 0..pad {
1387                    x = draw_text_span(frame, x, y, " ", Style::default(), max_x);
1388                }
1389                x = draw_text_span(frame, x, y, "  ", Style::default(), max_x);
1390                draw_text_span(frame, x, y, &entry.desc, desc_style, max_x);
1391                y += 1;
1392            }
1393
1394            // Blank line between groups (except after last)
1395            if i + 1 < groups.len() {
1396                y += 1;
1397            }
1398        }
1399    }
1400}
1401
1402impl Widget for KeybindingHints {
1403    fn render(&self, area: Rect, frame: &mut Frame) {
1404        let entries = self.visible_entries();
1405        if entries.is_empty() || area.is_empty() {
1406            return;
1407        }
1408
1409        match self.mode {
1410            HelpMode::Short => {
1411                // In short mode, render all entries inline using Help widget.
1412                let help = Help::new()
1413                    .with_mode(HelpMode::Short)
1414                    .with_key_style(self.key_style)
1415                    .with_desc_style(self.desc_style)
1416                    .with_separator_style(self.separator_style)
1417                    .with_separator(self.separator.clone())
1418                    .with_ellipsis(self.ellipsis.clone())
1419                    .with_entries(entries);
1420                Widget::render(&help, area, frame);
1421            }
1422            HelpMode::Full => {
1423                if self.show_categories {
1424                    self.render_full_grouped(&entries, area, frame);
1425                } else {
1426                    let help = Help::new()
1427                        .with_mode(HelpMode::Full)
1428                        .with_key_style(self.key_style)
1429                        .with_desc_style(self.desc_style)
1430                        .with_entries(entries);
1431                    Widget::render(&help, area, frame);
1432                }
1433            }
1434        }
1435    }
1436
1437    fn is_essential(&self) -> bool {
1438        false
1439    }
1440}
1441
1442#[cfg(test)]
1443mod tests {
1444    use super::*;
1445    use ftui_render::frame::Frame;
1446    use ftui_render::grapheme_pool::GraphemePool;
1447    use proptest::prelude::*;
1448    use proptest::string::string_regex;
1449    use std::time::Instant;
1450
1451    #[test]
1452    fn new_help_is_empty() {
1453        let help = Help::new();
1454        assert!(help.entries().is_empty());
1455        assert_eq!(help.mode(), HelpMode::Short);
1456    }
1457
1458    #[test]
1459    fn entry_builder() {
1460        let help = Help::new().entry("q", "quit").entry("^s", "save");
1461        assert_eq!(help.entries().len(), 2);
1462        assert_eq!(help.entries()[0].key, "q");
1463        assert_eq!(help.entries()[0].desc, "quit");
1464    }
1465
1466    #[test]
1467    fn with_entries_replaces() {
1468        let help = Help::new()
1469            .entry("old", "old")
1470            .with_entries(vec![HelpEntry::new("new", "new")]);
1471        assert_eq!(help.entries().len(), 1);
1472        assert_eq!(help.entries()[0].key, "new");
1473    }
1474
1475    #[test]
1476    fn disabled_entries_hidden() {
1477        let help = Help::new()
1478            .with_entry(HelpEntry::new("a", "shown"))
1479            .with_entry(HelpEntry::new("b", "hidden").with_enabled(false))
1480            .with_entry(HelpEntry::new("c", "also shown"));
1481        assert_eq!(help.enabled_entries().len(), 2);
1482    }
1483
1484    #[test]
1485    fn toggle_mode() {
1486        let mut help = Help::new();
1487        assert_eq!(help.mode(), HelpMode::Short);
1488        help.toggle_mode();
1489        assert_eq!(help.mode(), HelpMode::Full);
1490        help.toggle_mode();
1491        assert_eq!(help.mode(), HelpMode::Short);
1492    }
1493
1494    #[test]
1495    fn push_entry() {
1496        let mut help = Help::new();
1497        help.push_entry(HelpEntry::new("x", "action"));
1498        assert_eq!(help.entries().len(), 1);
1499    }
1500
1501    #[test]
1502    fn render_short_basic() {
1503        let help = Help::new().entry("q", "quit").entry("^s", "save");
1504
1505        let mut pool = GraphemePool::new();
1506        let mut frame = Frame::new(40, 1, &mut pool);
1507        let area = Rect::new(0, 0, 40, 1);
1508        Widget::render(&help, area, &mut frame);
1509
1510        // Check that key text appears in buffer
1511        let cell_q = frame.buffer.get(0, 0).unwrap();
1512        assert_eq!(cell_q.content.as_char(), Some('q'));
1513    }
1514
1515    #[test]
1516    fn render_short_truncation() {
1517        let help = Help::new()
1518            .entry("q", "quit")
1519            .entry("^s", "save")
1520            .entry("^x", "something very long that should not fit");
1521
1522        let mut pool = GraphemePool::new();
1523        let mut frame = Frame::new(20, 1, &mut pool);
1524        let area = Rect::new(0, 0, 20, 1);
1525        Widget::render(&help, area, &mut frame);
1526
1527        // First entry should be present
1528        let cell = frame.buffer.get(0, 0).unwrap();
1529        assert_eq!(cell.content.as_char(), Some('q'));
1530    }
1531
1532    #[test]
1533    fn render_short_empty_entries() {
1534        let help = Help::new();
1535
1536        let mut pool = GraphemePool::new();
1537        let mut frame = Frame::new(20, 1, &mut pool);
1538        let area = Rect::new(0, 0, 20, 1);
1539        Widget::render(&help, area, &mut frame);
1540
1541        // Buffer should remain default (empty cell)
1542        let cell = frame.buffer.get(0, 0).unwrap();
1543        assert!(cell.content.is_empty() || cell.content.as_char() == Some(' '));
1544    }
1545
1546    #[test]
1547    fn render_full_basic() {
1548        let help = Help::new()
1549            .with_mode(HelpMode::Full)
1550            .entry("q", "quit")
1551            .entry("^s", "save file");
1552
1553        let mut pool = GraphemePool::new();
1554        let mut frame = Frame::new(30, 5, &mut pool);
1555        let area = Rect::new(0, 0, 30, 5);
1556        Widget::render(&help, area, &mut frame);
1557
1558        // First row should have "q" key
1559        let cell = frame.buffer.get(0, 0).unwrap();
1560        assert!(cell.content.as_char() == Some(' ') || cell.content.as_char() == Some('q'));
1561        // Second row should have "^s" key (right-padded: " ^s")
1562        let cell_row2 = frame.buffer.get(0, 1).unwrap();
1563        assert!(
1564            cell_row2.content.as_char() == Some('^') || cell_row2.content.as_char() == Some(' ')
1565        );
1566    }
1567
1568    #[test]
1569    fn render_full_respects_height() {
1570        let help = Help::new()
1571            .with_mode(HelpMode::Full)
1572            .entry("a", "first")
1573            .entry("b", "second")
1574            .entry("c", "third");
1575
1576        let mut pool = GraphemePool::new();
1577        // Only 2 rows available
1578        let mut frame = Frame::new(30, 2, &mut pool);
1579        let area = Rect::new(0, 0, 30, 2);
1580        Widget::render(&help, area, &mut frame);
1581
1582        // Only first two entries should render (height=2)
1583        // No crash, no panic
1584    }
1585
1586    #[test]
1587    fn help_entry_equality() {
1588        let a = HelpEntry::new("q", "quit");
1589        let b = HelpEntry::new("q", "quit");
1590        let c = HelpEntry::new("x", "exit");
1591        assert_eq!(a, b);
1592        assert_ne!(a, c);
1593    }
1594
1595    #[test]
1596    fn help_entry_disabled() {
1597        let entry = HelpEntry::new("q", "quit").with_enabled(false);
1598        assert!(!entry.enabled);
1599    }
1600
1601    #[test]
1602    fn with_separator() {
1603        let help = Help::new().with_separator(" | ");
1604        assert_eq!(help.separator, " | ");
1605    }
1606
1607    #[test]
1608    fn with_ellipsis() {
1609        let help = Help::new().with_ellipsis("...");
1610        assert_eq!(help.ellipsis, "...");
1611    }
1612
1613    #[test]
1614    fn render_zero_area() {
1615        let help = Help::new().entry("q", "quit");
1616
1617        let mut pool = GraphemePool::new();
1618        let mut frame = Frame::new(20, 1, &mut pool);
1619        let area = Rect::new(0, 0, 0, 0);
1620        Widget::render(&help, area, &mut frame); // Should not panic
1621    }
1622
1623    #[test]
1624    fn is_not_essential() {
1625        let help = Help::new();
1626        assert!(!help.is_essential());
1627    }
1628
1629    #[test]
1630    fn render_full_alignment() {
1631        // Verify key column alignment in full mode
1632        let help = Help::new()
1633            .with_mode(HelpMode::Full)
1634            .entry("q", "quit")
1635            .entry("ctrl+s", "save");
1636
1637        let mut pool = GraphemePool::new();
1638        let mut frame = Frame::new(30, 3, &mut pool);
1639        let area = Rect::new(0, 0, 30, 3);
1640        Widget::render(&help, area, &mut frame);
1641
1642        // "q" is 1 char, "ctrl+s" is 6 chars, max_key_w = 6
1643        // Row 0: "q      quit" (q + 5 spaces + 2 spaces + quit)
1644        // Row 1: "ctrl+s  save"
1645        // Check that descriptions start at the same column
1646        // Key col = 6, gap = 2, desc starts at col 8
1647    }
1648
1649    #[test]
1650    fn default_impl() {
1651        let help = Help::default();
1652        assert!(help.entries().is_empty());
1653    }
1654
1655    #[test]
1656    fn cache_hit_same_hints() {
1657        let help = Help::new().entry("q", "quit").entry("^s", "save");
1658        let mut state = HelpRenderState::default();
1659        let mut pool = GraphemePool::new();
1660        let mut frame = Frame::new(40, 1, &mut pool);
1661        let area = Rect::new(0, 0, 40, 1);
1662
1663        StatefulWidget::render(&help, area, &mut frame, &mut state);
1664        let stats_after_first = state.stats();
1665        StatefulWidget::render(&help, area, &mut frame, &mut state);
1666        let stats_after_second = state.stats();
1667
1668        assert!(
1669            stats_after_second.hits > stats_after_first.hits,
1670            "Second render should be a cache hit"
1671        );
1672        assert!(state.dirty_rects().is_empty(), "No dirty rects on hit");
1673    }
1674
1675    #[test]
1676    fn dirty_rect_only_changes() {
1677        let mut help = Help::new()
1678            .with_mode(HelpMode::Full)
1679            .entry("q", "quit")
1680            .entry("w", "write")
1681            .entry("e", "edit");
1682
1683        let mut state = HelpRenderState::default();
1684        let mut pool = GraphemePool::new();
1685        let mut frame = Frame::new(40, 3, &mut pool);
1686        let area = Rect::new(0, 0, 40, 3);
1687
1688        StatefulWidget::render(&help, area, &mut frame, &mut state);
1689
1690        help.entries[1].desc.clear();
1691        help.entries[1].desc.push_str("save");
1692
1693        StatefulWidget::render(&help, area, &mut frame, &mut state);
1694        let dirty = state.take_dirty_rects();
1695
1696        assert_eq!(dirty.len(), 1, "Only one row should be dirty");
1697        assert_eq!(dirty[0].y, 1, "Second entry row should be dirty");
1698    }
1699
1700    proptest! {
1701        #[test]
1702        fn prop_cache_hits_on_stable_entries(entries in prop::collection::vec(
1703            (string_regex("[a-z]{1,6}").unwrap(), string_regex("[a-z]{1,10}").unwrap()),
1704            1..6
1705        )) {
1706            let mut help = Help::new();
1707            for (key, desc) in entries {
1708                help = help.entry(key, desc);
1709            }
1710            let mut state = HelpRenderState::default();
1711            let mut pool = GraphemePool::new();
1712            let mut frame = Frame::new(80, 1, &mut pool);
1713            let area = Rect::new(0, 0, 80, 1);
1714
1715            StatefulWidget::render(&help, area, &mut frame, &mut state);
1716            let stats_after_first = state.stats();
1717            StatefulWidget::render(&help, area, &mut frame, &mut state);
1718            let stats_after_second = state.stats();
1719
1720            prop_assert!(stats_after_second.hits > stats_after_first.hits);
1721            prop_assert!(state.dirty_rects().is_empty());
1722        }
1723    }
1724
1725    #[test]
1726    fn perf_micro_hint_update() {
1727        let mut help = Help::new()
1728            .with_mode(HelpMode::Short)
1729            .entry("^T", "Theme")
1730            .entry("^C", "Quit")
1731            .entry("?", "Help")
1732            .entry("F12", "Debug");
1733
1734        let mut state = HelpRenderState::default();
1735        let mut pool = GraphemePool::new();
1736        let mut frame = Frame::new(120, 1, &mut pool);
1737        let area = Rect::new(0, 0, 120, 1);
1738
1739        StatefulWidget::render(&help, area, &mut frame, &mut state);
1740
1741        let iterations = 200u32;
1742        let mut times_us = Vec::with_capacity(iterations as usize);
1743        for i in 0..iterations {
1744            let label = if i % 2 == 0 { "Close" } else { "Open" };
1745            help.entries[1].desc.clear();
1746            help.entries[1].desc.push_str(label);
1747
1748            let start = Instant::now();
1749            StatefulWidget::render(&help, area, &mut frame, &mut state);
1750            let elapsed = start.elapsed();
1751            times_us.push(elapsed.as_micros() as u64);
1752        }
1753
1754        times_us.sort();
1755        let len = times_us.len();
1756        let p50 = times_us[len / 2];
1757        let p95 = times_us[((len as f64 * 0.95) as usize).min(len.saturating_sub(1))];
1758        let p99 = times_us[((len as f64 * 0.99) as usize).min(len.saturating_sub(1))];
1759        let updates_per_sec = 1_000_000u64.checked_div(p50).unwrap_or(0);
1760
1761        eprintln!(
1762            "{{\"ts\":\"2026-02-03T00:00:00Z\",\"case\":\"help_hint_update\",\"iterations\":{},\"p50_us\":{},\"p95_us\":{},\"p99_us\":{},\"updates_per_sec\":{},\"hits\":{},\"misses\":{},\"dirty_updates\":{}}}",
1763            iterations,
1764            p50,
1765            p95,
1766            p99,
1767            updates_per_sec,
1768            state.stats().hits,
1769            state.stats().misses,
1770            state.stats().dirty_updates
1771        );
1772
1773        // Budget: keep p95 under 2ms in CI (500 updates/sec).
1774        assert!(p95 <= 2000, "p95 too slow: {p95}us");
1775    }
1776
1777    // ── HelpCategory tests ─────────────────────────────────────────
1778
1779    #[test]
1780    fn help_category_default_is_general() {
1781        assert_eq!(HelpCategory::default(), HelpCategory::General);
1782    }
1783
1784    #[test]
1785    fn help_category_labels() {
1786        assert_eq!(HelpCategory::General.label(), "General");
1787        assert_eq!(HelpCategory::Navigation.label(), "Navigation");
1788        assert_eq!(HelpCategory::Editing.label(), "Editing");
1789        assert_eq!(HelpCategory::File.label(), "File");
1790        assert_eq!(HelpCategory::View.label(), "View");
1791        assert_eq!(HelpCategory::Global.label(), "Global");
1792        assert_eq!(
1793            HelpCategory::Custom("My Section".into()).label(),
1794            "My Section"
1795        );
1796    }
1797
1798    #[test]
1799    fn help_entry_with_category() {
1800        let entry = HelpEntry::new("q", "quit").with_category(HelpCategory::Navigation);
1801        assert_eq!(entry.category, HelpCategory::Navigation);
1802    }
1803
1804    #[test]
1805    fn help_entry_default_category_is_general() {
1806        let entry = HelpEntry::new("q", "quit");
1807        assert_eq!(entry.category, HelpCategory::General);
1808    }
1809
1810    #[test]
1811    fn category_changes_entry_hash() {
1812        let a = HelpEntry::new("q", "quit");
1813        let b = HelpEntry::new("q", "quit").with_category(HelpCategory::Navigation);
1814        assert_ne!(Help::entry_hash(&a), Help::entry_hash(&b));
1815    }
1816
1817    // ── KeyFormat tests ────────────────────────────────────────────
1818
1819    #[test]
1820    fn key_format_default_is_plain() {
1821        assert_eq!(KeyFormat::default(), KeyFormat::Plain);
1822    }
1823
1824    // ── KeybindingHints tests ──────────────────────────────────────
1825
1826    #[test]
1827    fn keybinding_hints_new_is_empty() {
1828        let hints = KeybindingHints::new();
1829        assert!(hints.global_entries().is_empty());
1830        assert!(hints.contextual_entries().is_empty());
1831        assert_eq!(hints.mode(), HelpMode::Short);
1832        assert_eq!(hints.key_format(), KeyFormat::Plain);
1833    }
1834
1835    #[test]
1836    fn keybinding_hints_default() {
1837        let hints = KeybindingHints::default();
1838        assert!(hints.global_entries().is_empty());
1839    }
1840
1841    #[test]
1842    fn keybinding_hints_global_entry() {
1843        let hints = KeybindingHints::new()
1844            .global_entry("q", "quit")
1845            .global_entry("^s", "save");
1846        assert_eq!(hints.global_entries().len(), 2);
1847        assert_eq!(hints.global_entries()[0].key, "q");
1848        assert_eq!(hints.global_entries()[0].category, HelpCategory::Global);
1849    }
1850
1851    #[test]
1852    fn keybinding_hints_categorized_entries() {
1853        let hints = KeybindingHints::new()
1854            .global_entry_categorized("Tab", "next", HelpCategory::Navigation)
1855            .global_entry_categorized("q", "quit", HelpCategory::Global);
1856        assert_eq!(hints.global_entries()[0].category, HelpCategory::Navigation);
1857        assert_eq!(hints.global_entries()[1].category, HelpCategory::Global);
1858    }
1859
1860    #[test]
1861    fn keybinding_hints_contextual_entry() {
1862        let hints = KeybindingHints::new()
1863            .contextual_entry("^s", "save")
1864            .contextual_entry_categorized("^f", "find", HelpCategory::Editing);
1865        assert_eq!(hints.contextual_entries().len(), 2);
1866        assert_eq!(
1867            hints.contextual_entries()[0].category,
1868            HelpCategory::General
1869        );
1870        assert_eq!(
1871            hints.contextual_entries()[1].category,
1872            HelpCategory::Editing
1873        );
1874    }
1875
1876    #[test]
1877    fn keybinding_hints_with_prebuilt_entries() {
1878        let global = HelpEntry::new("q", "quit").with_category(HelpCategory::Global);
1879        let ctx = HelpEntry::new("^s", "save").with_category(HelpCategory::File);
1880        let hints = KeybindingHints::new()
1881            .with_global_entry(global)
1882            .with_contextual_entry(ctx);
1883        assert_eq!(hints.global_entries().len(), 1);
1884        assert_eq!(hints.contextual_entries().len(), 1);
1885    }
1886
1887    #[test]
1888    fn keybinding_hints_toggle_mode() {
1889        let mut hints = KeybindingHints::new();
1890        assert_eq!(hints.mode(), HelpMode::Short);
1891        hints.toggle_mode();
1892        assert_eq!(hints.mode(), HelpMode::Full);
1893        hints.toggle_mode();
1894        assert_eq!(hints.mode(), HelpMode::Short);
1895    }
1896
1897    #[test]
1898    fn keybinding_hints_set_show_context() {
1899        let mut hints = KeybindingHints::new()
1900            .global_entry("q", "quit")
1901            .contextual_entry("^s", "save");
1902
1903        // Context off: only global visible
1904        let visible = hints.visible_entries();
1905        assert_eq!(visible.len(), 1);
1906
1907        // Context on: both visible
1908        hints.set_show_context(true);
1909        let visible = hints.visible_entries();
1910        assert_eq!(visible.len(), 2);
1911    }
1912
1913    #[test]
1914    fn keybinding_hints_bracketed_format() {
1915        let hints = KeybindingHints::new()
1916            .with_key_format(KeyFormat::Bracketed)
1917            .global_entry("q", "quit");
1918        let visible = hints.visible_entries();
1919        assert_eq!(visible[0].key, "[q]");
1920    }
1921
1922    #[test]
1923    fn keybinding_hints_plain_format() {
1924        let hints = KeybindingHints::new()
1925            .with_key_format(KeyFormat::Plain)
1926            .global_entry("q", "quit");
1927        let visible = hints.visible_entries();
1928        assert_eq!(visible[0].key, "q");
1929    }
1930
1931    #[test]
1932    fn keybinding_hints_disabled_entries_hidden() {
1933        let hints = KeybindingHints::new()
1934            .with_global_entry(HelpEntry::new("a", "shown"))
1935            .with_global_entry(HelpEntry::new("b", "hidden").with_enabled(false));
1936        let visible = hints.visible_entries();
1937        assert_eq!(visible.len(), 1);
1938        assert_eq!(visible[0].key, "a");
1939    }
1940
1941    #[test]
1942    fn keybinding_hints_grouped_entries() {
1943        let entries = vec![
1944            HelpEntry::new("Tab", "next").with_category(HelpCategory::Navigation),
1945            HelpEntry::new("q", "quit").with_category(HelpCategory::Global),
1946            HelpEntry::new("S-Tab", "prev").with_category(HelpCategory::Navigation),
1947        ];
1948        let groups = KeybindingHints::grouped_entries(&entries);
1949        assert_eq!(groups.len(), 2);
1950        assert_eq!(*groups[0].0, HelpCategory::Navigation);
1951        assert_eq!(groups[0].1.len(), 2);
1952        assert_eq!(*groups[1].0, HelpCategory::Global);
1953        assert_eq!(groups[1].1.len(), 1);
1954    }
1955
1956    #[test]
1957    fn keybinding_hints_render_short() {
1958        let hints = KeybindingHints::new()
1959            .global_entry("q", "quit")
1960            .global_entry("^s", "save");
1961
1962        let mut pool = GraphemePool::new();
1963        let mut frame = Frame::new(40, 1, &mut pool);
1964        let area = Rect::new(0, 0, 40, 1);
1965        Widget::render(&hints, area, &mut frame);
1966
1967        // First char should be 'q' (plain format)
1968        let cell = frame.buffer.get(0, 0).unwrap();
1969        assert_eq!(cell.content.as_char(), Some('q'));
1970    }
1971
1972    #[test]
1973    fn keybinding_hints_render_short_bracketed() {
1974        let hints = KeybindingHints::new()
1975            .with_key_format(KeyFormat::Bracketed)
1976            .global_entry("q", "quit");
1977
1978        let mut pool = GraphemePool::new();
1979        let mut frame = Frame::new(40, 1, &mut pool);
1980        let area = Rect::new(0, 0, 40, 1);
1981        Widget::render(&hints, area, &mut frame);
1982
1983        // First char should be '[' (bracketed format)
1984        let cell = frame.buffer.get(0, 0).unwrap();
1985        assert_eq!(cell.content.as_char(), Some('['));
1986    }
1987
1988    #[test]
1989    fn keybinding_hints_render_full_grouped() {
1990        let hints = KeybindingHints::new()
1991            .with_mode(HelpMode::Full)
1992            .with_show_categories(true)
1993            .global_entry_categorized("Tab", "next", HelpCategory::Navigation)
1994            .global_entry_categorized("q", "quit", HelpCategory::Global);
1995
1996        let mut pool = GraphemePool::new();
1997        let mut frame = Frame::new(40, 10, &mut pool);
1998        let area = Rect::new(0, 0, 40, 10);
1999        Widget::render(&hints, area, &mut frame);
2000
2001        // Row 0 should contain category header "Navigation"
2002        let mut row0 = String::new();
2003        for x in 0..40u16 {
2004            if let Some(cell) = frame.buffer.get(x, 0)
2005                && let Some(ch) = cell.content.as_char()
2006            {
2007                row0.push(ch);
2008            }
2009        }
2010        assert!(
2011            row0.contains("Navigation"),
2012            "First row should be Navigation header: {row0}"
2013        );
2014    }
2015
2016    #[test]
2017    fn keybinding_hints_render_full_no_categories() {
2018        let hints = KeybindingHints::new()
2019            .with_mode(HelpMode::Full)
2020            .with_show_categories(false)
2021            .global_entry("q", "quit")
2022            .global_entry("^s", "save");
2023
2024        let mut pool = GraphemePool::new();
2025        let mut frame = Frame::new(40, 5, &mut pool);
2026        let area = Rect::new(0, 0, 40, 5);
2027        // Should not panic
2028        Widget::render(&hints, area, &mut frame);
2029    }
2030
2031    #[test]
2032    fn keybinding_hints_render_empty() {
2033        let hints = KeybindingHints::new();
2034
2035        let mut pool = GraphemePool::new();
2036        let mut frame = Frame::new(20, 1, &mut pool);
2037        let area = Rect::new(0, 0, 20, 1);
2038        // Should not panic
2039        Widget::render(&hints, area, &mut frame);
2040    }
2041
2042    #[test]
2043    fn keybinding_hints_render_zero_area() {
2044        let hints = KeybindingHints::new().global_entry("q", "quit");
2045
2046        let mut pool = GraphemePool::new();
2047        let mut frame = Frame::new(20, 1, &mut pool);
2048        let area = Rect::new(0, 0, 0, 0);
2049        // Should not panic
2050        Widget::render(&hints, area, &mut frame);
2051    }
2052
2053    #[test]
2054    fn keybinding_hints_is_not_essential() {
2055        let hints = KeybindingHints::new();
2056        assert!(!hints.is_essential());
2057    }
2058
2059    // ── Property tests for KeybindingHints ──────────────────────────
2060
2061    proptest! {
2062        #[test]
2063        fn prop_visible_entries_count(
2064            n_global in 0..5usize,
2065            n_ctx in 0..5usize,
2066            show_ctx in proptest::bool::ANY,
2067        ) {
2068            let mut hints = KeybindingHints::new().with_show_context(show_ctx);
2069            for i in 0..n_global {
2070                hints = hints.global_entry(format!("g{i}"), format!("global {i}"));
2071            }
2072            for i in 0..n_ctx {
2073                hints = hints.contextual_entry(format!("c{i}"), format!("ctx {i}"));
2074            }
2075            let visible = hints.visible_entries();
2076            let expected = if show_ctx { n_global + n_ctx } else { n_global };
2077            prop_assert_eq!(visible.len(), expected);
2078        }
2079
2080        #[test]
2081        fn prop_bracketed_keys_wrapped(
2082            keys in prop::collection::vec(string_regex("[a-z]{1,4}").unwrap(), 1..5),
2083        ) {
2084            let mut hints = KeybindingHints::new().with_key_format(KeyFormat::Bracketed);
2085            for key in &keys {
2086                hints = hints.global_entry(key.clone(), "action");
2087            }
2088            let visible = hints.visible_entries();
2089            for entry in &visible {
2090                prop_assert!(entry.key.starts_with('['), "Key should start with [: {}", entry.key);
2091                prop_assert!(entry.key.ends_with(']'), "Key should end with ]: {}", entry.key);
2092            }
2093        }
2094
2095        #[test]
2096        fn prop_grouped_preserves_count(
2097            entries in prop::collection::vec(
2098                (string_regex("[a-z]{1,4}").unwrap(), 0..3u8),
2099                1..8
2100            ),
2101        ) {
2102            let help_entries: Vec<HelpEntry> = entries.into_iter().map(|(key, cat_idx)| {
2103                let cat = match cat_idx {
2104                    0 => HelpCategory::Navigation,
2105                    1 => HelpCategory::Editing,
2106                    _ => HelpCategory::Global,
2107                };
2108                HelpEntry::new(key, "action").with_category(cat)
2109            }).collect();
2110
2111            let total = help_entries.len();
2112            let groups = KeybindingHints::grouped_entries(&help_entries);
2113            let grouped_total: usize = groups.iter().map(|(_, v)| v.len()).sum();
2114            prop_assert_eq!(total, grouped_total, "Grouping should preserve total entry count");
2115        }
2116
2117        #[test]
2118        fn prop_render_no_panic(
2119            n_global in 0..5usize,
2120            n_ctx in 0..5usize,
2121            width in 1..80u16,
2122            height in 1..20u16,
2123            show_ctx in proptest::bool::ANY,
2124            use_full in proptest::bool::ANY,
2125            use_brackets in proptest::bool::ANY,
2126            show_cats in proptest::bool::ANY,
2127        ) {
2128            let mode = if use_full { HelpMode::Full } else { HelpMode::Short };
2129            let fmt = if use_brackets { KeyFormat::Bracketed } else { KeyFormat::Plain };
2130            let mut hints = KeybindingHints::new()
2131                .with_mode(mode)
2132                .with_key_format(fmt)
2133                .with_show_context(show_ctx)
2134                .with_show_categories(show_cats);
2135
2136            for i in 0..n_global {
2137                hints = hints.global_entry(format!("g{i}"), format!("global action {i}"));
2138            }
2139            for i in 0..n_ctx {
2140                hints = hints.contextual_entry(format!("c{i}"), format!("ctx action {i}"));
2141            }
2142
2143            let mut pool = GraphemePool::new();
2144            let mut frame = Frame::new(width, height, &mut pool);
2145            let area = Rect::new(0, 0, width, height);
2146            Widget::render(&hints, area, &mut frame);
2147            // No panic = pass
2148        }
2149    }
2150
2151    // ========================================================================
2152    // Edge-case tests (bd-1noim)
2153    // ========================================================================
2154
2155    // ── HelpCategory edge cases ─────────────────────────────────────
2156
2157    #[test]
2158    fn help_category_custom_empty_string() {
2159        let cat = HelpCategory::Custom(String::new());
2160        assert_eq!(cat.label(), "");
2161    }
2162
2163    #[test]
2164    fn help_category_custom_eq() {
2165        let a = HelpCategory::Custom("Foo".into());
2166        let b = HelpCategory::Custom("Foo".into());
2167        let c = HelpCategory::Custom("Bar".into());
2168        assert_eq!(a, b);
2169        assert_ne!(a, c);
2170    }
2171
2172    #[test]
2173    fn help_category_clone() {
2174        let cat = HelpCategory::Navigation;
2175        let cloned = cat.clone();
2176        assert_eq!(cat, cloned);
2177    }
2178
2179    #[test]
2180    fn help_category_hash_consistency() {
2181        use std::collections::hash_map::DefaultHasher;
2182        let mut h1 = DefaultHasher::new();
2183        let mut h2 = DefaultHasher::new();
2184        HelpCategory::File.hash(&mut h1);
2185        HelpCategory::File.hash(&mut h2);
2186        assert_eq!(h1.finish(), h2.finish());
2187    }
2188
2189    #[test]
2190    fn help_category_debug_format() {
2191        let dbg = format!("{:?}", HelpCategory::General);
2192        assert!(dbg.contains("General"));
2193        let dbg_custom = format!("{:?}", HelpCategory::Custom("X".into()));
2194        assert!(dbg_custom.contains("Custom"));
2195    }
2196
2197    // ── HelpEntry edge cases ────────────────────────────────────────
2198
2199    #[test]
2200    fn help_entry_empty_key_and_desc() {
2201        let entry = HelpEntry::new("", "");
2202        assert!(entry.key.is_empty());
2203        assert!(entry.desc.is_empty());
2204        assert!(entry.enabled);
2205    }
2206
2207    #[test]
2208    fn help_entry_clone() {
2209        let entry = HelpEntry::new("q", "quit").with_category(HelpCategory::File);
2210        let cloned = entry.clone();
2211        assert_eq!(entry, cloned);
2212    }
2213
2214    #[test]
2215    fn help_entry_debug_format() {
2216        let entry = HelpEntry::new("^s", "save");
2217        let dbg = format!("{:?}", entry);
2218        assert!(dbg.contains("HelpEntry"));
2219        assert!(dbg.contains("save"));
2220    }
2221
2222    // ── HelpMode edge cases ─────────────────────────────────────────
2223
2224    #[test]
2225    fn help_mode_default_is_short() {
2226        assert_eq!(HelpMode::default(), HelpMode::Short);
2227    }
2228
2229    #[test]
2230    fn help_mode_eq_and_hash() {
2231        use std::collections::hash_map::DefaultHasher;
2232        assert_eq!(HelpMode::Short, HelpMode::Short);
2233        assert_ne!(HelpMode::Short, HelpMode::Full);
2234        let mut h = DefaultHasher::new();
2235        HelpMode::Full.hash(&mut h);
2236        // Just verify it doesn't panic
2237    }
2238
2239    #[test]
2240    fn help_mode_copy() {
2241        let m = HelpMode::Full;
2242        let m2 = m; // Copy
2243        assert_eq!(m, m2);
2244    }
2245
2246    // ── Help rendering edge cases ───────────────────────────────────
2247
2248    #[test]
2249    fn render_short_all_disabled() {
2250        let help = Help::new()
2251            .with_entry(HelpEntry::new("a", "first").with_enabled(false))
2252            .with_entry(HelpEntry::new("b", "second").with_enabled(false));
2253
2254        let mut pool = GraphemePool::new();
2255        let mut frame = Frame::new(40, 1, &mut pool);
2256        let area = Rect::new(0, 0, 40, 1);
2257        Widget::render(&help, area, &mut frame);
2258        // No visible entries, buffer stays default
2259        let cell = frame.buffer.get(0, 0).unwrap();
2260        assert!(cell.content.is_empty() || cell.content.as_char() == Some(' '));
2261    }
2262
2263    #[test]
2264    fn render_short_empty_key_desc_entries_skipped() {
2265        let help = Help::new()
2266            .with_entry(HelpEntry::new("", ""))
2267            .entry("q", "quit");
2268
2269        let mut pool = GraphemePool::new();
2270        let mut frame = Frame::new(40, 1, &mut pool);
2271        let area = Rect::new(0, 0, 40, 1);
2272        Widget::render(&help, area, &mut frame);
2273        // The empty entry produces no text but separator logic still fires.
2274        // Verify 'q' appears somewhere in the rendered row.
2275        let mut found_q = false;
2276        for x in 0..40 {
2277            if let Some(cell) = frame.buffer.get(x, 0)
2278                && cell.content.as_char() == Some('q')
2279            {
2280                found_q = true;
2281                break;
2282            }
2283        }
2284        assert!(found_q, "'q' should appear in the rendered row");
2285    }
2286
2287    #[test]
2288    fn render_short_width_one() {
2289        let help = Help::new().entry("q", "quit");
2290
2291        let mut pool = GraphemePool::new();
2292        let mut frame = Frame::new(1, 1, &mut pool);
2293        let area = Rect::new(0, 0, 1, 1);
2294        Widget::render(&help, area, &mut frame);
2295        // Should not panic; may show ellipsis or partial
2296    }
2297
2298    #[test]
2299    fn render_full_width_one() {
2300        let help = Help::new().with_mode(HelpMode::Full).entry("q", "quit");
2301
2302        let mut pool = GraphemePool::new();
2303        let mut frame = Frame::new(1, 5, &mut pool);
2304        let area = Rect::new(0, 0, 1, 5);
2305        Widget::render(&help, area, &mut frame);
2306        // Should not panic
2307    }
2308
2309    #[test]
2310    fn render_full_height_one() {
2311        let help = Help::new()
2312            .with_mode(HelpMode::Full)
2313            .entry("a", "first")
2314            .entry("b", "second")
2315            .entry("c", "third");
2316
2317        let mut pool = GraphemePool::new();
2318        let mut frame = Frame::new(40, 1, &mut pool);
2319        let area = Rect::new(0, 0, 40, 1);
2320        Widget::render(&help, area, &mut frame);
2321        // Only first entry should render
2322    }
2323
2324    #[test]
2325    fn render_short_single_entry_exact_fit() {
2326        // "q quit" = 6 chars, area width = 6
2327        let help = Help::new().entry("q", "quit");
2328
2329        let mut pool = GraphemePool::new();
2330        let mut frame = Frame::new(6, 1, &mut pool);
2331        let area = Rect::new(0, 0, 6, 1);
2332        Widget::render(&help, area, &mut frame);
2333        let cell = frame.buffer.get(0, 0).unwrap();
2334        assert_eq!(cell.content.as_char(), Some('q'));
2335    }
2336
2337    #[test]
2338    fn render_short_empty_separator() {
2339        let help = Help::new()
2340            .with_separator("")
2341            .entry("a", "x")
2342            .entry("b", "y");
2343
2344        let mut pool = GraphemePool::new();
2345        let mut frame = Frame::new(40, 1, &mut pool);
2346        let area = Rect::new(0, 0, 40, 1);
2347        Widget::render(&help, area, &mut frame);
2348        // Both entries render without separator
2349        let cell = frame.buffer.get(0, 0).unwrap();
2350        assert_eq!(cell.content.as_char(), Some('a'));
2351    }
2352
2353    // ── Help builder edge cases ─────────────────────────────────────
2354
2355    #[test]
2356    fn help_with_mode_full() {
2357        let help = Help::new().with_mode(HelpMode::Full);
2358        assert_eq!(help.mode(), HelpMode::Full);
2359    }
2360
2361    #[test]
2362    fn help_clone() {
2363        let help = Help::new()
2364            .entry("q", "quit")
2365            .with_separator(" | ")
2366            .with_ellipsis("...");
2367        let cloned = help.clone();
2368        assert_eq!(cloned.entries().len(), 1);
2369        assert_eq!(cloned.separator, " | ");
2370        assert_eq!(cloned.ellipsis, "...");
2371    }
2372
2373    #[test]
2374    fn help_debug_format() {
2375        let help = Help::new().entry("q", "quit");
2376        let dbg = format!("{:?}", help);
2377        assert!(dbg.contains("Help"));
2378    }
2379
2380    // ── HelpRenderState edge cases ──────────────────────────────────
2381
2382    #[test]
2383    fn help_render_state_default() {
2384        let state = HelpRenderState::default();
2385        assert!(state.cache.is_none());
2386        assert!(state.dirty_rects().is_empty());
2387        assert_eq!(state.stats().hits, 0);
2388        assert_eq!(state.stats().misses, 0);
2389    }
2390
2391    #[test]
2392    fn help_render_state_clear_dirty_rects() {
2393        let mut state = HelpRenderState::default();
2394        state.dirty_rects.push(Rect::new(0, 0, 10, 1));
2395        assert_eq!(state.dirty_rects().len(), 1);
2396        state.clear_dirty_rects();
2397        assert!(state.dirty_rects().is_empty());
2398    }
2399
2400    #[test]
2401    fn help_render_state_take_dirty_rects() {
2402        let mut state = HelpRenderState::default();
2403        state.dirty_rects.push(Rect::new(0, 0, 5, 1));
2404        state.dirty_rects.push(Rect::new(0, 1, 5, 1));
2405        let taken = state.take_dirty_rects();
2406        assert_eq!(taken.len(), 2);
2407        assert!(state.dirty_rects().is_empty()); // cleared after take
2408    }
2409
2410    #[test]
2411    fn help_render_state_reset_stats() {
2412        let mut state = HelpRenderState::default();
2413        state.stats.hits = 42;
2414        state.stats.misses = 7;
2415        state.stats.dirty_updates = 3;
2416        state.stats.layout_rebuilds = 2;
2417        state.reset_stats();
2418        assert_eq!(state.stats(), HelpCacheStats::default());
2419    }
2420
2421    #[test]
2422    fn help_cache_stats_default() {
2423        let stats = HelpCacheStats::default();
2424        assert_eq!(stats.hits, 0);
2425        assert_eq!(stats.misses, 0);
2426        assert_eq!(stats.dirty_updates, 0);
2427        assert_eq!(stats.layout_rebuilds, 0);
2428    }
2429
2430    #[test]
2431    fn help_cache_stats_clone_eq() {
2432        let a = HelpCacheStats {
2433            hits: 5,
2434            misses: 2,
2435            dirty_updates: 1,
2436            layout_rebuilds: 3,
2437        };
2438        let b = a;
2439        assert_eq!(a, b);
2440    }
2441
2442    #[test]
2443    fn stateful_render_empty_area_clears_cache() {
2444        let help = Help::new().entry("q", "quit");
2445        let mut state = HelpRenderState::default();
2446        let mut pool = GraphemePool::new();
2447        let mut frame = Frame::new(40, 1, &mut pool);
2448        let area = Rect::new(0, 0, 40, 1);
2449
2450        // First render populates cache
2451        StatefulWidget::render(&help, area, &mut frame, &mut state);
2452        assert!(state.cache.is_some());
2453
2454        // Render with empty area clears cache
2455        let empty = Rect::new(0, 0, 0, 0);
2456        StatefulWidget::render(&help, empty, &mut frame, &mut state);
2457        assert!(state.cache.is_none());
2458    }
2459
2460    #[test]
2461    fn stateful_render_cache_miss_on_area_change() {
2462        let help = Help::new().entry("q", "quit").entry("^s", "save");
2463        let mut state = HelpRenderState::default();
2464        let mut pool = GraphemePool::new();
2465        let mut frame = Frame::new(80, 5, &mut pool);
2466
2467        StatefulWidget::render(&help, Rect::new(0, 0, 40, 1), &mut frame, &mut state);
2468        let misses1 = state.stats().misses;
2469
2470        StatefulWidget::render(&help, Rect::new(0, 0, 60, 1), &mut frame, &mut state);
2471        let misses2 = state.stats().misses;
2472
2473        assert!(misses2 > misses1, "Area change should cause cache miss");
2474    }
2475
2476    #[test]
2477    fn stateful_render_cache_miss_on_mode_change() {
2478        let mut help = Help::new().entry("q", "quit");
2479        let mut state = HelpRenderState::default();
2480        let mut pool = GraphemePool::new();
2481        let mut frame = Frame::new(40, 5, &mut pool);
2482        let area = Rect::new(0, 0, 40, 5);
2483
2484        StatefulWidget::render(&help, area, &mut frame, &mut state);
2485        let misses1 = state.stats().misses;
2486
2487        help.toggle_mode();
2488        StatefulWidget::render(&help, area, &mut frame, &mut state);
2489        let misses2 = state.stats().misses;
2490
2491        assert!(misses2 > misses1, "Mode change should cause cache miss");
2492    }
2493
2494    #[test]
2495    fn stateful_render_layout_rebuild_on_enabled_count_change() {
2496        let mut help = Help::new()
2497            .entry("q", "quit")
2498            .entry("^s", "save")
2499            .entry("^x", "exit");
2500        let mut state = HelpRenderState::default();
2501        let mut pool = GraphemePool::new();
2502        let mut frame = Frame::new(80, 1, &mut pool);
2503        let area = Rect::new(0, 0, 80, 1);
2504
2505        StatefulWidget::render(&help, area, &mut frame, &mut state);
2506        let rebuilds1 = state.stats().layout_rebuilds;
2507
2508        // Disable one entry
2509        help.entries[1].enabled = false;
2510        StatefulWidget::render(&help, area, &mut frame, &mut state);
2511        let rebuilds2 = state.stats().layout_rebuilds;
2512
2513        assert!(
2514            rebuilds2 > rebuilds1,
2515            "Enabled count change should trigger layout rebuild"
2516        );
2517    }
2518
2519    // ── KeyFormat edge cases ────────────────────────────────────────
2520
2521    #[test]
2522    fn key_format_eq_and_hash() {
2523        use std::collections::hash_map::DefaultHasher;
2524        assert_eq!(KeyFormat::Plain, KeyFormat::Plain);
2525        assert_ne!(KeyFormat::Plain, KeyFormat::Bracketed);
2526        let mut h = DefaultHasher::new();
2527        KeyFormat::Bracketed.hash(&mut h);
2528    }
2529
2530    #[test]
2531    fn key_format_copy() {
2532        let f = KeyFormat::Bracketed;
2533        let f2 = f;
2534        assert_eq!(f, f2);
2535    }
2536
2537    #[test]
2538    fn key_format_debug() {
2539        let dbg = format!("{:?}", KeyFormat::Bracketed);
2540        assert!(dbg.contains("Bracketed"));
2541    }
2542
2543    // ── KeybindingHints edge cases ──────────────────────────────────
2544
2545    #[test]
2546    fn keybinding_hints_clone() {
2547        let hints = KeybindingHints::new()
2548            .global_entry("q", "quit")
2549            .contextual_entry("^s", "save");
2550        let cloned = hints.clone();
2551        assert_eq!(cloned.global_entries().len(), 1);
2552        assert_eq!(cloned.contextual_entries().len(), 1);
2553    }
2554
2555    #[test]
2556    fn keybinding_hints_debug() {
2557        let hints = KeybindingHints::new().global_entry("q", "quit");
2558        let dbg = format!("{:?}", hints);
2559        assert!(dbg.contains("KeybindingHints"));
2560    }
2561
2562    #[test]
2563    fn keybinding_hints_with_separator() {
2564        let hints = KeybindingHints::new().with_separator(" | ");
2565        assert_eq!(hints.separator, " | ");
2566    }
2567
2568    #[test]
2569    fn keybinding_hints_with_styles() {
2570        let hints = KeybindingHints::new()
2571            .with_key_style(Style::new().bold())
2572            .with_desc_style(Style::default())
2573            .with_separator_style(Style::default())
2574            .with_category_style(Style::new().underline());
2575        // Just verify builder doesn't panic
2576        assert_eq!(hints.mode(), HelpMode::Short);
2577    }
2578
2579    #[test]
2580    fn keybinding_hints_visible_entries_disabled_contextual() {
2581        let hints = KeybindingHints::new()
2582            .with_show_context(true)
2583            .global_entry("q", "quit")
2584            .with_contextual_entry(HelpEntry::new("^s", "save").with_enabled(false));
2585        let visible = hints.visible_entries();
2586        // Only global "q" visible; disabled contextual "^s" hidden
2587        assert_eq!(visible.len(), 1);
2588        assert_eq!(visible[0].desc, "quit");
2589    }
2590
2591    #[test]
2592    fn keybinding_hints_empty_global_nonempty_ctx_hidden() {
2593        let hints = KeybindingHints::new()
2594            .contextual_entry("^s", "save")
2595            .contextual_entry("^f", "find");
2596        // Context off by default
2597        let visible = hints.visible_entries();
2598        assert!(visible.is_empty());
2599    }
2600
2601    #[test]
2602    fn keybinding_hints_render_full_grouped_height_limit() {
2603        let hints = KeybindingHints::new()
2604            .with_mode(HelpMode::Full)
2605            .with_show_categories(true)
2606            .global_entry_categorized("a", "first", HelpCategory::Navigation)
2607            .global_entry_categorized("b", "second", HelpCategory::Navigation)
2608            .global_entry_categorized("c", "third", HelpCategory::Navigation)
2609            .global_entry_categorized("d", "fourth", HelpCategory::Global)
2610            .global_entry_categorized("e", "fifth", HelpCategory::Global);
2611
2612        let mut pool = GraphemePool::new();
2613        // Only 3 rows: header + 2 entries, can't fit all
2614        let mut frame = Frame::new(40, 3, &mut pool);
2615        let area = Rect::new(0, 0, 40, 3);
2616        Widget::render(&hints, area, &mut frame);
2617        // Should not panic; clips to available height
2618    }
2619
2620    #[test]
2621    fn keybinding_hints_render_empty_area() {
2622        let hints = KeybindingHints::new().global_entry("q", "quit");
2623        let mut pool = GraphemePool::new();
2624        let mut frame = Frame::new(1, 1, &mut pool);
2625        Widget::render(&hints, Rect::new(0, 0, 0, 0), &mut frame);
2626        // Should not panic (is_empty check)
2627    }
2628
2629    // ── Help entry_hash edge cases ──────────────────────────────────
2630
2631    #[test]
2632    fn entry_hash_differs_for_different_keys() {
2633        let a = HelpEntry::new("q", "quit");
2634        let b = HelpEntry::new("x", "quit");
2635        assert_ne!(Help::entry_hash(&a), Help::entry_hash(&b));
2636    }
2637
2638    #[test]
2639    fn entry_hash_differs_for_different_descs() {
2640        let a = HelpEntry::new("q", "quit");
2641        let b = HelpEntry::new("q", "exit");
2642        assert_ne!(Help::entry_hash(&a), Help::entry_hash(&b));
2643    }
2644
2645    #[test]
2646    fn entry_hash_differs_for_enabled_flag() {
2647        let a = HelpEntry::new("q", "quit");
2648        let b = HelpEntry::new("q", "quit").with_enabled(false);
2649        assert_ne!(Help::entry_hash(&a), Help::entry_hash(&b));
2650    }
2651
2652    #[test]
2653    fn entry_hash_same_for_equal_entries() {
2654        let a = HelpEntry::new("q", "quit");
2655        let b = HelpEntry::new("q", "quit");
2656        assert_eq!(Help::entry_hash(&a), Help::entry_hash(&b));
2657    }
2658
2659    // ── Additional edge-case tests (bd-1noim, LavenderStone) ─────────
2660
2661    // ── HelpCategory: variant inequality + Custom("General") != General ──
2662
2663    #[test]
2664    fn help_category_custom_general_not_eq_general() {
2665        // Custom("General") and General are different enum variants
2666        assert_ne!(
2667            HelpCategory::Custom("General".into()),
2668            HelpCategory::General
2669        );
2670    }
2671
2672    #[test]
2673    fn help_category_all_variants_distinct() {
2674        let variants: Vec<HelpCategory> = vec![
2675            HelpCategory::General,
2676            HelpCategory::Navigation,
2677            HelpCategory::Editing,
2678            HelpCategory::File,
2679            HelpCategory::View,
2680            HelpCategory::Global,
2681            HelpCategory::Custom("X".into()),
2682        ];
2683        for (i, a) in variants.iter().enumerate() {
2684            for (j, b) in variants.iter().enumerate() {
2685                if i != j {
2686                    assert_ne!(a, b, "Variant {i} should differ from variant {j}");
2687                }
2688            }
2689        }
2690    }
2691
2692    // ── HelpEntry: field-by-field hash sensitivity ───────────────────
2693
2694    #[test]
2695    fn help_entry_hash_differs_by_category() {
2696        let a = HelpEntry::new("q", "quit");
2697        let b = HelpEntry::new("q", "quit").with_category(HelpCategory::File);
2698        assert_ne!(Help::entry_hash(&a), Help::entry_hash(&b));
2699    }
2700
2701    #[test]
2702    fn help_entry_only_key_no_desc_renders() {
2703        let help = Help::new().with_entry(HelpEntry::new("q", ""));
2704        let mut pool = GraphemePool::new();
2705        let mut frame = Frame::new(20, 1, &mut pool);
2706        let area = Rect::new(0, 0, 20, 1);
2707        Widget::render(&help, area, &mut frame);
2708        // "q " should render (key + space + empty desc)
2709        let cell = frame.buffer.get(0, 0).unwrap();
2710        assert_eq!(cell.content.as_char(), Some('q'));
2711    }
2712
2713    #[test]
2714    fn help_entry_only_desc_no_key_renders() {
2715        let help = Help::new().with_entry(HelpEntry::new("", "quit"));
2716        let mut pool = GraphemePool::new();
2717        let mut frame = Frame::new(20, 1, &mut pool);
2718        let area = Rect::new(0, 0, 20, 1);
2719        Widget::render(&help, area, &mut frame);
2720        // " quit" should render (empty key + space + desc)
2721        let cell = frame.buffer.get(1, 0).unwrap();
2722        assert_eq!(cell.content.as_char(), Some('q'));
2723    }
2724
2725    #[test]
2726    fn help_entry_unicode_key_and_desc() {
2727        let help = Help::new().with_entry(HelpEntry::new("\u{2191}", "up arrow"));
2728        let mut pool = GraphemePool::new();
2729        let mut frame = Frame::new(20, 1, &mut pool);
2730        let area = Rect::new(0, 0, 20, 1);
2731        Widget::render(&help, area, &mut frame);
2732    }
2733
2734    #[test]
2735    fn help_entry_chained_builder_overrides() {
2736        let entry = HelpEntry::new("q", "quit")
2737            .with_enabled(false)
2738            .with_category(HelpCategory::File)
2739            .with_enabled(true)
2740            .with_category(HelpCategory::View);
2741        assert!(entry.enabled);
2742        assert_eq!(entry.category, HelpCategory::View);
2743    }
2744
2745    // ── Help: rendering with area offsets ─────────────────────────────
2746
2747    #[test]
2748    fn render_short_area_offset() {
2749        let help = Help::new().entry("x", "action");
2750        let mut pool = GraphemePool::new();
2751        let mut frame = Frame::new(40, 5, &mut pool);
2752        let area = Rect::new(5, 2, 20, 1);
2753        Widget::render(&help, area, &mut frame);
2754        let cell = frame.buffer.get(5, 2).unwrap();
2755        assert_eq!(cell.content.as_char(), Some('x'));
2756        // Column 0,0 should be untouched
2757        let cell_origin = frame.buffer.get(0, 0).unwrap();
2758        assert!(cell_origin.content.is_empty() || cell_origin.content.as_char() == Some(' '));
2759    }
2760
2761    #[test]
2762    fn render_full_area_offset() {
2763        let help = Help::new().with_mode(HelpMode::Full).entry("q", "quit");
2764        let mut pool = GraphemePool::new();
2765        let mut frame = Frame::new(40, 5, &mut pool);
2766        let area = Rect::new(3, 1, 20, 3);
2767        Widget::render(&help, area, &mut frame);
2768        let cell = frame.buffer.get(3, 1).unwrap();
2769        assert_eq!(cell.content.as_char(), Some('q'));
2770    }
2771
2772    // ── Help: full mode with all entries disabled ─────────────────────
2773
2774    #[test]
2775    fn render_full_all_disabled() {
2776        let help = Help::new()
2777            .with_mode(HelpMode::Full)
2778            .with_entry(HelpEntry::new("a", "first").with_enabled(false))
2779            .with_entry(HelpEntry::new("b", "second").with_enabled(false));
2780        let mut pool = GraphemePool::new();
2781        let mut frame = Frame::new(30, 3, &mut pool);
2782        let area = Rect::new(0, 0, 30, 3);
2783        Widget::render(&help, area, &mut frame);
2784    }
2785
2786    // ── Help: ellipsis with empty ellipsis string ─────────────────────
2787
2788    #[test]
2789    fn render_short_empty_ellipsis_string() {
2790        let help = Help::new()
2791            .with_ellipsis("")
2792            .entry("q", "quit")
2793            .entry("w", "this is a very long description that overflows");
2794        let mut pool = GraphemePool::new();
2795        let mut frame = Frame::new(12, 1, &mut pool);
2796        let area = Rect::new(0, 0, 12, 1);
2797        Widget::render(&help, area, &mut frame);
2798    }
2799
2800    // ── Help: entry wider than entire area ────────────────────────────
2801
2802    #[test]
2803    fn render_short_entry_wider_than_area() {
2804        let help = Help::new().entry("verylongkey", "very long description text");
2805        let mut pool = GraphemePool::new();
2806        let mut frame = Frame::new(3, 1, &mut pool);
2807        let area = Rect::new(0, 0, 3, 1);
2808        Widget::render(&help, area, &mut frame);
2809    }
2810
2811    // ── Stateful: style change invalidates cache ──────────────────────
2812
2813    #[test]
2814    fn stateful_cache_invalidated_on_style_change() {
2815        let help1 = Help::new().entry("q", "quit");
2816        let help2 = Help::new()
2817            .entry("q", "quit")
2818            .with_key_style(Style::new().italic());
2819        let mut state = HelpRenderState::default();
2820        let mut pool = GraphemePool::new();
2821        let mut frame = Frame::new(40, 1, &mut pool);
2822        let area = Rect::new(0, 0, 40, 1);
2823
2824        StatefulWidget::render(&help1, area, &mut frame, &mut state);
2825        let misses_1 = state.stats().misses;
2826
2827        StatefulWidget::render(&help2, area, &mut frame, &mut state);
2828        assert!(
2829            state.stats().misses > misses_1,
2830            "Style change should cause cache miss"
2831        );
2832    }
2833
2834    // ── Stateful: entry addition triggers layout rebuild ──────────────
2835
2836    #[test]
2837    fn stateful_entry_addition_rebuilds_layout() {
2838        let mut help = Help::new().entry("q", "quit");
2839        let mut state = HelpRenderState::default();
2840        let mut pool = GraphemePool::new();
2841        let mut frame = Frame::new(40, 3, &mut pool);
2842        let area = Rect::new(0, 0, 40, 3);
2843
2844        StatefulWidget::render(&help, area, &mut frame, &mut state);
2845        let rebuilds_1 = state.stats().layout_rebuilds;
2846
2847        help.push_entry(HelpEntry::new("w", "write"));
2848        StatefulWidget::render(&help, area, &mut frame, &mut state);
2849        assert!(
2850            state.stats().layout_rebuilds > rebuilds_1,
2851            "Entry addition should rebuild layout"
2852        );
2853    }
2854
2855    // ── Stateful: separator change invalidates cache ──────────────────
2856
2857    #[test]
2858    fn stateful_separator_change_invalidates_cache() {
2859        let help1 = Help::new()
2860            .with_separator(" | ")
2861            .entry("q", "quit")
2862            .entry("w", "write");
2863        let help2 = Help::new()
2864            .with_separator(" - ")
2865            .entry("q", "quit")
2866            .entry("w", "write");
2867        let mut state = HelpRenderState::default();
2868        let mut pool = GraphemePool::new();
2869        let mut frame = Frame::new(40, 1, &mut pool);
2870        let area = Rect::new(0, 0, 40, 1);
2871
2872        StatefulWidget::render(&help1, area, &mut frame, &mut state);
2873        let misses_1 = state.stats().misses;
2874
2875        StatefulWidget::render(&help2, area, &mut frame, &mut state);
2876        assert!(
2877            state.stats().misses > misses_1,
2878            "Separator change should cause cache miss"
2879        );
2880    }
2881
2882    // ── Stateful: dirty update in full mode tracks correct rects ──────
2883
2884    #[test]
2885    fn stateful_full_mode_dirty_update_multiple() {
2886        let mut help = Help::new()
2887            .with_mode(HelpMode::Full)
2888            .entry("q", "quit")
2889            .entry("w", "save")
2890            .entry("e", "edit");
2891        let mut state = HelpRenderState::default();
2892        let mut pool = GraphemePool::new();
2893        let mut frame = Frame::new(40, 5, &mut pool);
2894        let area = Rect::new(0, 0, 40, 5);
2895
2896        StatefulWidget::render(&help, area, &mut frame, &mut state);
2897
2898        // Change two entries (same-length descs to stay within slot width)
2899        help.entries[0].desc = "exit".to_string();
2900        help.entries[2].desc = "view".to_string();
2901        StatefulWidget::render(&help, area, &mut frame, &mut state);
2902        let dirty = state.take_dirty_rects();
2903        assert_eq!(dirty.len(), 2, "Two changed entries produce 2 dirty rects");
2904    }
2905
2906    // ── Stateful: short mode dirty update ─────────────────────────────
2907
2908    #[test]
2909    fn stateful_short_mode_dirty_update() {
2910        let mut help = Help::new()
2911            .with_mode(HelpMode::Short)
2912            .entry("q", "quit")
2913            .entry("w", "write");
2914        let mut state = HelpRenderState::default();
2915        let mut pool = GraphemePool::new();
2916        let mut frame = Frame::new(40, 1, &mut pool);
2917        let area = Rect::new(0, 0, 40, 1);
2918
2919        StatefulWidget::render(&help, area, &mut frame, &mut state);
2920
2921        help.entries[0].desc = "exit".to_string();
2922        StatefulWidget::render(&help, area, &mut frame, &mut state);
2923        assert!(
2924            state.stats().dirty_updates > 0,
2925            "Changed desc should trigger dirty update"
2926        );
2927    }
2928
2929    // ── Layout builder edge cases ────────────────────────────────────
2930
2931    #[test]
2932    fn build_short_layout_no_enabled_entries() {
2933        let help = Help::new().with_entry(HelpEntry::new("a", "b").with_enabled(false));
2934        let layout = help.build_short_layout(Rect::new(0, 0, 40, 1));
2935        assert!(layout.entries.is_empty());
2936        assert!(layout.ellipsis.is_none());
2937    }
2938
2939    #[test]
2940    fn build_full_layout_no_enabled_entries() {
2941        let help = Help::new().with_entry(HelpEntry::new("a", "b").with_enabled(false));
2942        let layout = help.build_full_layout(Rect::new(0, 0, 40, 5));
2943        assert!(layout.entries.is_empty());
2944        assert_eq!(layout.max_key_width, 0);
2945    }
2946
2947    #[test]
2948    fn build_short_layout_triggers_ellipsis() {
2949        let help = Help::new()
2950            .entry("longkey", "long description text here")
2951            .entry("another", "even longer description text");
2952        let layout = help.build_short_layout(Rect::new(0, 0, 20, 1));
2953        // Second entry won't fit; ellipsis should appear
2954        assert!(
2955            !layout.entries.is_empty() || layout.ellipsis.is_some(),
2956            "Should have entries or ellipsis"
2957        );
2958    }
2959
2960    #[test]
2961    fn build_full_layout_respects_height() {
2962        let help = Help::new()
2963            .entry("a", "first")
2964            .entry("b", "second")
2965            .entry("c", "third")
2966            .entry("d", "fourth");
2967        let layout = help.build_full_layout(Rect::new(0, 0, 40, 2));
2968        assert_eq!(layout.entries.len(), 2, "Should respect height=2 limit");
2969    }
2970
2971    #[test]
2972    fn build_short_layout_zero_width() {
2973        let help = Help::new().entry("q", "quit");
2974        let layout = help.build_short_layout(Rect::new(0, 0, 0, 1));
2975        assert!(layout.entries.is_empty());
2976    }
2977
2978    #[test]
2979    fn build_full_layout_zero_height() {
2980        let help = Help::new().entry("q", "quit");
2981        let layout = help.build_full_layout(Rect::new(0, 0, 40, 0));
2982        assert!(layout.entries.is_empty());
2983    }
2984
2985    // ── entry_fits_slot edge cases ───────────────────────────────────
2986
2987    #[test]
2988    fn entry_fits_slot_out_of_bounds_index_short() {
2989        let help = Help::new().entry("q", "quit");
2990        let layout = help.build_short_layout(Rect::new(0, 0, 40, 1));
2991        let entry = &help.entries[0];
2992        assert!(!entry_fits_slot(entry, 999, &layout));
2993    }
2994
2995    #[test]
2996    fn entry_fits_slot_out_of_bounds_index_full() {
2997        let help = Help::new().entry("q", "quit");
2998        let layout = help.build_full_layout(Rect::new(0, 0, 40, 1));
2999        let entry = &help.entries[0];
3000        assert!(!entry_fits_slot(entry, 999, &layout));
3001    }
3002
3003    #[test]
3004    fn entry_fits_slot_full_key_too_wide() {
3005        let help = Help::new().entry("x", "d");
3006        let layout = help.build_full_layout(Rect::new(0, 0, 40, 1));
3007        if !layout.entries.is_empty() {
3008            let wide_entry = HelpEntry::new("verylongkeyname", "d");
3009            assert!(!entry_fits_slot(&wide_entry, 0, &layout));
3010        }
3011    }
3012
3013    // ── collect_enabled_indices edge cases ────────────────────────────
3014
3015    #[test]
3016    fn collect_enabled_indices_all_disabled() {
3017        let entries = vec![
3018            HelpEntry::new("a", "b").with_enabled(false),
3019            HelpEntry::new("c", "d").with_enabled(false),
3020        ];
3021        let mut out = Vec::new();
3022        let count = collect_enabled_indices(&entries, &mut out);
3023        assert_eq!(count, 0);
3024        assert!(out.is_empty());
3025    }
3026
3027    #[test]
3028    fn collect_enabled_indices_empty_entries_filtered() {
3029        let entries = vec![
3030            HelpEntry::new("", ""),
3031            HelpEntry::new("q", "quit"),
3032            HelpEntry::new("", ""),
3033        ];
3034        let mut out = Vec::new();
3035        let count = collect_enabled_indices(&entries, &mut out);
3036        assert_eq!(count, 1);
3037        assert_eq!(out, vec![1]);
3038    }
3039
3040    #[test]
3041    fn collect_enabled_indices_mixed() {
3042        let entries = vec![
3043            HelpEntry::new("a", "first"),
3044            HelpEntry::new("b", "second").with_enabled(false),
3045            HelpEntry::new("", ""),
3046            HelpEntry::new("d", "fourth"),
3047        ];
3048        let mut out = Vec::new();
3049        let count = collect_enabled_indices(&entries, &mut out);
3050        assert_eq!(count, 2);
3051        assert_eq!(out, vec![0, 3]);
3052    }
3053
3054    #[test]
3055    fn collect_enabled_indices_clears_previous_data() {
3056        let entries = vec![HelpEntry::new("a", "b")];
3057        let mut out = vec![99, 100, 101];
3058        let count = collect_enabled_indices(&entries, &mut out);
3059        assert_eq!(count, 1);
3060        assert_eq!(out, vec![0]);
3061    }
3062
3063    // ── blit_cache edge cases ────────────────────────────────────────
3064
3065    #[test]
3066    fn blit_cache_none_is_noop() {
3067        let mut pool = GraphemePool::new();
3068        let mut frame = Frame::new(10, 1, &mut pool);
3069        let area = Rect::new(0, 0, 10, 1);
3070        blit_cache(None, area, &mut frame);
3071    }
3072
3073    // ── StyleKey edge cases ──────────────────────────────────────────
3074
3075    #[test]
3076    fn style_key_from_default_style() {
3077        let sk = StyleKey::from(Style::default());
3078        assert!(sk.fg.is_none());
3079        assert!(sk.bg.is_none());
3080        assert!(sk.attrs.is_none());
3081    }
3082
3083    #[test]
3084    fn style_key_from_styled() {
3085        let style = Style::new().bold();
3086        let sk = StyleKey::from(style);
3087        assert!(sk.attrs.is_some());
3088    }
3089
3090    #[test]
3091    fn style_key_equality_and_hash() {
3092        use std::collections::hash_map::DefaultHasher;
3093        let a = StyleKey::from(Style::new().italic());
3094        let b = StyleKey::from(Style::new().italic());
3095        assert_eq!(a, b);
3096        let mut h1 = DefaultHasher::new();
3097        let mut h2 = DefaultHasher::new();
3098        a.hash(&mut h1);
3099        b.hash(&mut h2);
3100        assert_eq!(h1.finish(), h2.finish());
3101    }
3102
3103    #[test]
3104    fn style_key_different_styles_ne() {
3105        let a = StyleKey::from(Style::new().bold());
3106        let b = StyleKey::from(Style::new().italic());
3107        assert_ne!(a, b);
3108    }
3109
3110    // ── hash_str edge cases ──────────────────────────────────────────
3111
3112    #[test]
3113    fn hash_str_empty_deterministic() {
3114        assert_eq!(Help::hash_str(""), Help::hash_str(""));
3115    }
3116
3117    #[test]
3118    fn hash_str_different_strings_differ() {
3119        assert_ne!(Help::hash_str("abc"), Help::hash_str("def"));
3120    }
3121
3122    // ── KeybindingHints: custom categories in grouped view ───────────
3123
3124    #[test]
3125    fn keybinding_hints_custom_categories_grouped() {
3126        let entries = vec![
3127            HelpEntry::new("a", "one").with_category(HelpCategory::Custom("Alpha".into())),
3128            HelpEntry::new("b", "two").with_category(HelpCategory::Custom("Beta".into())),
3129            HelpEntry::new("c", "three").with_category(HelpCategory::Custom("Alpha".into())),
3130        ];
3131        let groups = KeybindingHints::grouped_entries(&entries);
3132        assert_eq!(groups.len(), 2);
3133        assert_eq!(groups[0].1.len(), 2); // Alpha has 2 entries
3134        assert_eq!(groups[1].1.len(), 1); // Beta has 1 entry
3135    }
3136
3137    #[test]
3138    fn keybinding_hints_all_contextual_context_on() {
3139        let hints = KeybindingHints::new()
3140            .with_show_context(true)
3141            .contextual_entry("^s", "save")
3142            .contextual_entry("^f", "find");
3143        let visible = hints.visible_entries();
3144        assert_eq!(visible.len(), 2);
3145    }
3146
3147    #[test]
3148    fn keybinding_hints_format_key_plain_empty() {
3149        let hints = KeybindingHints::new().with_key_format(KeyFormat::Plain);
3150        assert_eq!(hints.format_key(""), "");
3151    }
3152
3153    #[test]
3154    fn keybinding_hints_format_key_bracketed_empty() {
3155        let hints = KeybindingHints::new().with_key_format(KeyFormat::Bracketed);
3156        assert_eq!(hints.format_key(""), "[]");
3157    }
3158
3159    #[test]
3160    fn keybinding_hints_format_key_bracketed_unicode() {
3161        let hints = KeybindingHints::new().with_key_format(KeyFormat::Bracketed);
3162        assert_eq!(hints.format_key("\u{2191}"), "[\u{2191}]");
3163    }
3164
3165    // ── KeybindingHints: render full grouped with single category ─────
3166
3167    #[test]
3168    fn keybinding_hints_render_full_grouped_single_category() {
3169        let hints = KeybindingHints::new()
3170            .with_mode(HelpMode::Full)
3171            .with_show_categories(true)
3172            .global_entry_categorized("a", "first", HelpCategory::Navigation)
3173            .global_entry_categorized("b", "second", HelpCategory::Navigation);
3174        let mut pool = GraphemePool::new();
3175        let mut frame = Frame::new(40, 10, &mut pool);
3176        let area = Rect::new(0, 0, 40, 10);
3177        Widget::render(&hints, area, &mut frame);
3178        // Single category: header "Navigation" + 2 entries, no trailing blank
3179    }
3180
3181    // ── HelpCacheStats trait coverage ─────────────────────────────────
3182
3183    #[test]
3184    fn help_cache_stats_ne() {
3185        let a = HelpCacheStats::default();
3186        let b = HelpCacheStats {
3187            hits: 1,
3188            ..Default::default()
3189        };
3190        assert_ne!(a, b);
3191    }
3192
3193    #[test]
3194    fn help_cache_stats_debug() {
3195        let stats = HelpCacheStats {
3196            hits: 5,
3197            misses: 2,
3198            dirty_updates: 1,
3199            layout_rebuilds: 3,
3200        };
3201        let dbg = format!("{stats:?}");
3202        assert!(dbg.contains("hits"));
3203        assert!(dbg.contains("misses"));
3204        assert!(dbg.contains("dirty_updates"));
3205        assert!(dbg.contains("layout_rebuilds"));
3206    }
3207
3208    // ── LayoutKey copy + hash coverage ────────────────────────────────
3209
3210    #[test]
3211    fn layout_key_copy_and_eq() {
3212        let help = Help::new().entry("q", "quit");
3213        let area = Rect::new(0, 0, 40, 1);
3214        let key1 = help.layout_key(area, DegradationLevel::Full);
3215        let key2 = key1; // Copy
3216        assert_eq!(key1, key2);
3217    }
3218
3219    #[test]
3220    fn layout_key_differs_by_mode() {
3221        let help_s = Help::new().entry("q", "quit");
3222        let help_f = Help::new().with_mode(HelpMode::Full).entry("q", "quit");
3223        let area = Rect::new(0, 0, 40, 1);
3224        let deg = DegradationLevel::Full;
3225        assert_ne!(help_s.layout_key(area, deg), help_f.layout_key(area, deg));
3226    }
3227
3228    #[test]
3229    fn layout_key_differs_by_dimensions() {
3230        let help = Help::new().entry("q", "quit");
3231        let deg = DegradationLevel::Full;
3232        let k1 = help.layout_key(Rect::new(0, 0, 40, 1), deg);
3233        let k2 = help.layout_key(Rect::new(0, 0, 80, 1), deg);
3234        assert_ne!(k1, k2);
3235    }
3236
3237    #[test]
3238    fn layout_key_hash_consistent() {
3239        use std::collections::hash_map::DefaultHasher;
3240        let help = Help::new().entry("q", "quit");
3241        let key = help.layout_key(Rect::new(0, 0, 40, 1), DegradationLevel::Full);
3242        let mut h1 = DefaultHasher::new();
3243        let mut h2 = DefaultHasher::new();
3244        key.hash(&mut h1);
3245        key.hash(&mut h2);
3246        assert_eq!(h1.finish(), h2.finish());
3247    }
3248}